diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ebe9c..95992cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ board can be cross-checked against what actually shipped. ### Added +- Any call can be opened in an editor, changed and sent again. The editor starts from the method, address, header fields and body that were recorded. A value the redaction masked was never kept, so it is flagged and not sent unless it is typed again. What goes out is recorded as a new call that names the one it repeats, marked in the list and linked from its overview. It needs `dart:io`, so the web does not offer it (M15) - A line above the list that says when Flutwire may be missing calls, with the reason and what to change: a client or adapter that does not use `dart:io`, an adapter that ran in another isolate, or something that replaced the capture's `HttpOverrides`. It stays out of sight until Flutwire has seen one of them happen, and it names the client or the isolate. `Flutwire.coverage` lists the notes, and an adapter that stands down for the capture reports through `Flutwire.watchStandDown`. The example app has a probe for each case (M24) - An example for each adapter, so the package page on pub.dev has one to show (M34) diff --git a/README.md b/README.md index fe4591d..e6c6ebe 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ When an app misbehaves on a phone, the call that explains it is usually out of reach. The console shows only what a logger was told to print. A proxy needs a certificate on the device and a changed Wi-Fi setting. Every HTTP client in the app has to be wired by hand before it says anything, and when the app is killed the evidence goes with it. -Flutwire records the calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A debug build needs one line in `main`; a release build records nothing. +Flutwire records the calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen. A debug build needs one line in `main`; a release build records nothing. ```bash flutter pub add flutwire @@ -50,7 +50,7 @@ curl -X POST 'https://api.example.com/v1/orders' \ --data-raw '{"items":[{"sku":"A-12","qty":2}]}' ``` -Flutwire reads traffic and never changes it. It does not proxy other apps, and it cannot see what never passes through Dart: Firestore, Auth and Storage speak their own protocols through native SDKs. When a call may have gone unseen, because a client does not use `dart:io`, because an adapter ran in another isolate or because something replaced the capture, the inspector says so above the list and says what to change. Authorization and cookie values are masked when a call is recorded, so they never reach memory, disk, a notification or an export. +Flutwire reads traffic and never changes it. It does not proxy other apps, and it cannot see what never passes through Dart: Firestore, Auth and Storage speak their own protocols through native SDKs. When a call may have gone unseen, because a client does not use `dart:io`, because an adapter ran in another isolate or because something replaced the capture, the inspector says so above the list and says what to change. Authorization and cookie values are masked when a call is recorded, so they never reach memory, disk, a notification or an export. The only request Flutwire makes is the one you edit and send again from a call, and it leaves out any value that was masked. In a release build `Flutwire.init` does nothing: no call is recorded, no file is written and no notification is posted. The plugin's native code still ships in the app, like any other plugin's, and on Android its manifest adds the notification permission to the merged one. diff --git a/packages/flutwire/README.md b/packages/flutwire/README.md index 60142cc..98008e6 100644 --- a/packages/flutwire/README.md +++ b/packages/flutwire/README.md @@ -6,7 +6,7 @@ **A network inspector for Flutter that opens from the notification shade.** -Flutwire records the HTTP calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. +Flutwire records the HTTP calls an app makes, keeps them on the device and lists the latest ones in the notification shade. Tapping it opens the inspector, and each call opens in full: URL, timings, headers, cookies, and the request and response bodies as a JSON tree, indented text, form fields, an image or a hex dump. A call can be edited and sent again from the same screen. ## Getting started @@ -37,6 +37,12 @@ MaterialApp( ); ``` +## Sending a call again + +Open a call and tap the replay button in the top bar. The method, the address, the header fields and the body can all be changed, and Send makes the request. What goes out is recorded as a new call that names the one it repeats. + +A value the redaction masked was never kept, so it is flagged and sent only if you type it again. A body that is not text goes out as it was recorded, unless only part of it was kept. Sending needs `dart:io`, so the web does not offer it. + ## Configuration ```dart diff --git a/packages/flutwire/lib/src/capture/wire/send_again.dart b/packages/flutwire/lib/src/capture/wire/send_again.dart new file mode 100644 index 0000000..c450561 --- /dev/null +++ b/packages/flutwire/lib/src/capture/wire/send_again.dart @@ -0,0 +1,3 @@ +export 'send_again_stub.dart' + if (dart.library.io) 'send_again_io.dart' + show canSendAgain, sendAgain; diff --git a/packages/flutwire/lib/src/capture/wire/send_again_io.dart b/packages/flutwire/lib/src/capture/wire/send_again_io.dart new file mode 100644 index 0000000..9b2c7b9 --- /dev/null +++ b/packages/flutwire/lib/src/capture/wire/send_again_io.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../engine.dart'; +import '../../resend.dart'; +import '../recorder.dart'; +import 'wire_io.dart'; + +/// How long a request may wait for the connection. +const Duration _connectTimeout = Duration(seconds: 30); + +/// How long a request may run, from the moment its connection is open. +const Duration _defaultDeadline = Duration(minutes: 2); + +/// Whether this platform can send a request again. +const bool canSendAgain = true; + +/// Sends [request] through `dart:io` and records it as a call that repeats the +/// one it names. Returns the id of the recorded call as soon as the request is +/// on its way, or null when nothing was recorded. It does not wait for the +/// answer: the call fills in as the answer arrives, like any other. +/// +/// The request goes out the way the app's own would, through the overrides the +/// app installed, so a proxy or a certificate rule it set applies here too. The +/// capture is taken out of the way, because this call is recorded once, by +/// itself, with the link to the call it repeats. +/// +/// A request that has not been answered after [deadline] is aborted and +/// recorded as failed. +Future sendAgain( + FlutwireEngine engine, + FlutwireResend request, { + Duration deadline = _defaultDeadline, +}) async { + var inner = HttpClient(); + if (inner is FlutwireHttpClient) inner = inner.unwrapped; + inner.connectionTimeout = _connectTimeout; + final client = FlutwireHttpClient(inner, engine, resentFrom: request.from); + final HttpClientRequest open; + try { + open = await client.openUrl(request.method, request.uri); + } on Object { + // The wrapper has already recorded the call as failed, with the reason. + client.close(); + return client.lastRecorder?.id; + } + final recorder = client.lastRecorder; + unawaited(_send(client, open, request, recorder, deadline)); + return recorder?.id; +} + +Future _send( + FlutwireHttpClient client, + HttpClientRequest open, + FlutwireResend request, + FlutwireCallRecorder? recorder, + Duration deadline, +) async { + final timer = Timer(deadline, () { + recorder?.fail( + TimeoutException('No answer within ${deadline.inSeconds} s', deadline), + ); + open.abort(); + }); + try { + // The first value of a field replaces what the client set by default, such + // as the user agent. Any further value is added next to it. + final seen = {}; + for (final field in request.headers.entries) { + if (seen.add(field.key.toLowerCase())) { + open.headers.set(field.key, field.value, preserveHeaderCase: true); + } else { + open.headers.add(field.key, field.value, preserveHeaderCase: true); + } + } + final body = request.body; + if (body != null) { + open.contentLength = body.length; + open.add(body); + } + final response = await open.close(); + await response.drain(); + } on Object catch (error, stack) { + // A failure on the wire is already on record. What is left is a field the + // client refuses and the abort above, and `fail` ignores a call that has + // already ended. + recorder?.fail(error, stack); + } finally { + timer.cancel(); + client.close(); + } +} diff --git a/packages/flutwire/lib/src/capture/wire/send_again_stub.dart b/packages/flutwire/lib/src/capture/wire/send_again_stub.dart new file mode 100644 index 0000000..8f31010 --- /dev/null +++ b/packages/flutwire/lib/src/capture/wire/send_again_stub.dart @@ -0,0 +1,13 @@ +import '../../engine.dart'; +import '../../resend.dart'; + +/// There is no `dart:io` on this platform, so a request cannot be sent again. +const bool canSendAgain = false; + +/// Never called where [canSendAgain] is false. +Future sendAgain( + FlutwireEngine engine, + FlutwireResend request, { + Duration deadline = const Duration(minutes: 2), +}) => + throw UnsupportedError('A request cannot be sent again on this platform.'); diff --git a/packages/flutwire/lib/src/capture/wire/wire_io.dart b/packages/flutwire/lib/src/capture/wire/wire_io.dart index 3841e83..10a3603 100644 --- a/packages/flutwire/lib/src/capture/wire/wire_io.dart +++ b/packages/flutwire/lib/src/capture/wire/wire_io.dart @@ -86,16 +86,29 @@ bool _stillCaptures(FlutwireEngine engine) { /// An `HttpClient` that records the calls made through it and delegates the /// work to the client it wraps. final class FlutwireHttpClient implements HttpClient { - /// Wraps [_inner], reporting to [_engine]. - FlutwireHttpClient(this._inner, this._engine); + /// Wraps [_inner], reporting to [_engine]. Every request made through the + /// client is recorded as a repeat of the call with the id [resentFrom] when + /// one is given. + FlutwireHttpClient(this._inner, this._engine, {this.resentFrom}); final HttpClient _inner; final FlutwireEngine _engine; final Set _open = {}; + FlutwireCallRecorder? _last; /// The engine this client reports to. FlutwireEngine get engine => _engine; + /// The call this client's requests repeat, if they do. + final int? resentFrom; + + /// The client this one wraps, without the recording. + HttpClient get unwrapped => _inner; + + /// The recorder of the latest request opened through this client, which is + /// null when that request was not recorded. + FlutwireCallRecorder? get lastRecorder => _last; + @override bool get autoUncompress => _inner.autoUncompress; @override @@ -174,7 +187,9 @@ final class FlutwireHttpClient implements HttpClient { method: method, uri: url, source: FlutwireSource.io, + resentFrom: resentFrom, ); + _last = recorder; final HttpClientRequest request; try { request = await open(); diff --git a/packages/flutwire/lib/src/engine.dart b/packages/flutwire/lib/src/engine.dart index 65dbd69..160a1bb 100644 --- a/packages/flutwire/lib/src/engine.dart +++ b/packages/flutwire/lib/src/engine.dart @@ -68,15 +68,22 @@ final class FlutwireEngine implements FlutwireRecorderHost { /// Starts recording a call, or returns null when it should not be recorded: /// recording is off or paused, or an ignore rule matches. + /// + /// A call that repeats the call with the id [resentFrom] is recorded + /// whatever the pause and the ignore rules say, because somebody asked for it + /// and is waiting to read the answer. FlutwireCallRecorder? begin({ required String method, required Uri uri, required FlutwireSource source, FlutwireHeaders requestHeaders = FlutwireHeaders.empty, + int? resentFrom, }) { try { final upper = method.toUpperCase(); - if (!_shouldRecord(upper, uri)) return null; + if (resentFrom == null ? !_shouldRecord(upper, uri) : !config.isActive) { + return null; + } final redaction = config.redaction; final call = FlutwireCall( id: _nextId(), @@ -88,6 +95,7 @@ final class FlutwireEngine implements FlutwireRecorderHost { headers: redaction.applyToHeaders(requestHeaders), body: FlutwireBody.empty(), ), + resentFrom: resentFrom, ); if (source == FlutwireSource.io) _noteIoCall(call); return FlutwireCallRecorder(this, call); diff --git a/packages/flutwire/lib/src/l10n/strings.dart b/packages/flutwire/lib/src/l10n/strings.dart index c6c54cc..aeaa611 100644 --- a/packages/flutwire/lib/src/l10n/strings.dart +++ b/packages/flutwire/lib/src/l10n/strings.dart @@ -482,4 +482,74 @@ abstract class FlutwireStrings { /// How often a blind spot was seen. String coverageSeen(int count); + + // Edit and resend + + /// The action that opens a call in an editor to send it again, and the + /// title of that editor. + String get resend; + + /// Sends the edited request. + String get send; + + /// What the send button says while the request leaves. + String get sending; + + /// Adds a row for a header field. + String get addHeader; + + /// The label of the name of a header field. + String get headerName; + + /// The label of the value of a header field. + String get headerValue; + + /// Takes a header field out of the request. + String get removeHeader; + + /// Marks a header field whose value Flutwire hid when it recorded the call. + String get hiddenValue; + + /// What sending does and where it goes. + String resendTarget(String host); + + /// The header fields that are not sent because their values were hidden, + /// named in [names]. + String resendHiddenNote(String names); + + /// A text body that was recorded only in part, with the sizes written out. + String resendBodyCut(String kept, String total); + + /// A body that is not text, which is sent exactly as it was recorded. + String resendBinaryBody(String size); + + /// A body that is not text and was recorded only in part, so it is not sent. + String get resendBinaryCut; + + /// The switch that sends the recorded body of a call, or leaves it out. + String get resendSendBody; + + /// Values in the body that the redaction hid, which show as [mask]. + String resendBodyHidden(String mask); + + /// Values in the address that the redaction hid, which show as [mask]. + String resendUrlHidden(String mask); + + /// The address in the editor cannot be sent to. + String get resendBadUrl; + + /// The request left but could not be put in the history. + String get resendNotRecorded; + + /// The request could not leave. + String get resendFailed; + + /// The label of the call that a resent call repeats. + String get resentFrom; + + /// What stands in for a repeated call that is no longer in the history. + String get resentFromGone; + + /// What a screen reader says about a call that was sent again. + String get resentBadge; } diff --git a/packages/flutwire/lib/src/l10n/strings_en.dart b/packages/flutwire/lib/src/l10n/strings_en.dart index 43c768d..03213a0 100644 --- a/packages/flutwire/lib/src/l10n/strings_en.dart +++ b/packages/flutwire/lib/src/l10n/strings_en.dart @@ -505,4 +505,80 @@ class FlutwireStringsEn extends FlutwireStrings { @override String coverageSeen(int count) => count == 1 ? 'Seen once' : 'Seen $count times'; + + @override + String get resend => 'Edit and resend'; + + @override + String get send => 'Send'; + + @override + String get sending => 'Sending'; + + @override + String get addHeader => 'Add header'; + + @override + String get headerName => 'Name'; + + @override + String get headerValue => 'Value'; + + @override + String get removeHeader => 'Remove header'; + + @override + String get hiddenValue => 'Hidden'; + + @override + String resendTarget(String host) => 'Sends a real request to $host.'; + + @override + String resendHiddenNote(String names) => + 'Not sent, because Flutwire hid their values when it recorded the call: ' + '$names. Type a value to send one.'; + + @override + String resendBodyCut(String kept, String total) => + 'The body was cut at $kept of $total. This is only the start of it.'; + + @override + String resendBinaryBody(String size) => + 'A body that is not text, $size. It is sent as it was recorded.'; + + @override + String get resendBinaryCut => + 'Only part of this body was recorded, so it is not sent.'; + + @override + String get resendSendBody => 'Send the recorded body'; + + @override + String resendBodyHidden(String mask) => + 'Some values in the body were hidden when the call was recorded and show ' + 'as $mask. Replace them to send the real ones.'; + + @override + String resendUrlHidden(String mask) => + 'A value in the address was hidden when the call was recorded and shows ' + 'as $mask. Replace it to send the real one.'; + + @override + String get resendBadUrl => + 'Enter a full address, such as https://example.com/path.'; + + @override + String get resendNotRecorded => 'Sent, but Flutwire could not record it.'; + + @override + String get resendFailed => 'The request could not be sent.'; + + @override + String get resentFrom => 'Sent again from'; + + @override + String get resentFromGone => 'A call that is no longer in the history'; + + @override + String get resentBadge => 'Sent again'; } diff --git a/packages/flutwire/lib/src/l10n/strings_it.dart b/packages/flutwire/lib/src/l10n/strings_it.dart index 6ef35a3..38f35aa 100644 --- a/packages/flutwire/lib/src/l10n/strings_it.dart +++ b/packages/flutwire/lib/src/l10n/strings_it.dart @@ -511,4 +511,83 @@ class FlutwireStringsIt extends FlutwireStrings { @override String coverageSeen(int count) => count == 1 ? 'Visto una volta' : 'Visto $count volte'; + + @override + String get resend => 'Modifica e reinvia'; + + @override + String get send => 'Invia'; + + @override + String get sending => 'Invio in corso'; + + @override + String get addHeader => 'Aggiungi header'; + + @override + String get headerName => 'Nome'; + + @override + String get headerValue => 'Valore'; + + @override + String get removeHeader => 'Rimuovi header'; + + @override + String get hiddenValue => 'Nascosto'; + + @override + String resendTarget(String host) => 'Invia una richiesta vera a $host.'; + + @override + String resendHiddenNote(String names) => + 'Non vengono inviati, perché Flutwire ne ha nascosto il valore quando ' + 'ha registrato la chiamata: $names. Scrivi un valore per inviarlo.'; + + @override + String resendBodyCut(String kept, String total) => + "Il corpo è stato tagliato a $kept su $total. Qui c'è solo la parte " + 'iniziale.'; + + @override + String resendBinaryBody(String size) => + "Un corpo che non è testo, $size. Viene inviato com'è stato registrato."; + + @override + String get resendBinaryCut => + 'Del corpo è stata registrata solo una parte, quindi non viene inviato.'; + + @override + String get resendSendBody => 'Invia il corpo registrato'; + + @override + String resendBodyHidden(String mask) => + 'Alcuni valori del corpo sono stati nascosti quando la chiamata è stata ' + 'registrata e compaiono come $mask. Sostituiscili per inviare quelli ' + 'veri.'; + + @override + String resendUrlHidden(String mask) => + "Un valore dell'indirizzo è stato nascosto quando la chiamata è stata " + 'registrata e compare come $mask. Sostituiscilo per inviare quello vero.'; + + @override + String get resendBadUrl => + 'Scrivi un indirizzo completo, per esempio https://example.com/percorso.'; + + @override + String get resendNotRecorded => + 'Inviata, ma Flutwire non è riuscito a registrarla.'; + + @override + String get resendFailed => 'Non è stato possibile inviare la richiesta.'; + + @override + String get resentFrom => 'Reinviata da'; + + @override + String get resentFromGone => 'Una chiamata che non è più nella cronologia'; + + @override + String get resentBadge => 'Reinviata'; } diff --git a/packages/flutwire/lib/src/model/call.dart b/packages/flutwire/lib/src/model/call.dart index 5275b03..b456d3b 100644 --- a/packages/flutwire/lib/src/model/call.dart +++ b/packages/flutwire/lib/src/model/call.dart @@ -319,6 +319,7 @@ final class FlutwireCall { this.error, this.graphql, this.pinned = false, + this.resentFrom, }); /// Restores a summary written by [toJson]. The bodies come back unloaded. @@ -375,6 +376,7 @@ final class FlutwireCall { ? FlutwireGraphql.fromJson(json['graphql']! as Map) : null, pinned: json['pinned'] as bool? ?? false, + resentFrom: (json['resentFrom'] as num?)?.toInt(), ); } @@ -417,6 +419,10 @@ final class FlutwireCall { /// Whether the user pinned this call so retention and clearing skip it. final bool pinned; + /// The id of the call this one repeats, when it was sent again from the + /// inspector. The original may be gone from the history by now. + final int? resentFrom; + /// The response status code, or null before one arrived. int? get statusCode => response?.statusCode; @@ -497,6 +503,7 @@ final class FlutwireCall { error: error ?? this.error, graphql: graphql ?? this.graphql, pinned: pinned ?? this.pinned, + resentFrom: resentFrom, ); /// A JSON-encodable summary. Body bytes are not included: the store keeps @@ -530,6 +537,7 @@ final class FlutwireCall { 'error': error?.toJson(), 'graphql': graphql?.toJson(), 'pinned': pinned, + if (resentFrom != null) 'resentFrom': resentFrom, }; @override diff --git a/packages/flutwire/lib/src/resend.dart b/packages/flutwire/lib/src/resend.dart new file mode 100644 index 0000000..927df48 --- /dev/null +++ b/packages/flutwire/lib/src/resend.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; + +import 'model/headers.dart'; + +/// The header fields the client writes from the request itself. Setting one by +/// hand only fights it: a stale `Content-Length` breaks a body that was edited. +const Set clientManagedHeaders = { + 'host', + 'content-length', + 'transfer-encoding', + 'connection', + 'keep-alive', +}; + +/// Whether [value] of the header field [name] is what the redaction policy left +/// in place of a secret, so it cannot be sent. +/// +/// A `Cookie` is hidden cookie by cookie, so one that still names its cookies is +/// hidden when any of them is. +bool isHiddenValue(String name, String value, String mask) { + if (mask.isEmpty) return false; + if (name.toLowerCase() == 'cookie') { + return _splitCookies(value, mask).dropped; + } + return value == mask; +} + +/// A request to send again, as the editor in the inspector built it. +/// +/// Flutwire never sends a value it hid: the fields that still hold the mask are +/// left out of [headers] and named in [hidden], so what goes on the wire is +/// exactly what the person read in the editor, minus the mask. +@immutable +final class FlutwireResend { + const FlutwireResend._({ + required this.from, + required this.method, + required this.uri, + required this.headers, + required this.hidden, + required this.body, + }); + + /// Prepares a request that repeats the call with the id [from]. + /// + /// [headers] are the fields as the editor holds them: blank rows, the fields + /// the client writes itself and the values that still hold [mask] are not + /// sent. A null or empty [body] sends no body. + factory FlutwireResend({ + required int from, + required String method, + required Uri uri, + required Iterable> headers, + required String mask, + Uint8List? body, + }) { + final kept = >[]; + final hidden = []; + void hide(String name) { + if (!hidden.contains(name)) hidden.add(name); + } + + for (final field in headers) { + final name = field.key.trim(); + if (name.isEmpty) continue; + final lower = name.toLowerCase(); + if (clientManagedHeaders.contains(lower)) continue; + if (lower == 'cookie' && mask.isNotEmpty) { + final cookies = _splitCookies(field.value, mask); + if (cookies.dropped) hide(name); + if (cookies.live.isNotEmpty) { + kept.add(MapEntry(name, cookies.live.join('; '))); + } + continue; + } + if (isHiddenValue(lower, field.value, mask)) { + hide(name); + continue; + } + kept.add(MapEntry(name, field.value)); + } + return FlutwireResend._( + from: from, + method: method.trim().toUpperCase(), + uri: uri, + headers: FlutwireHeaders(kept), + hidden: List.unmodifiable(hidden), + body: body == null || body.isEmpty ? null : body, + ); + } + + /// The id of the call this one repeats. + final int from; + + /// The method, in upper case. + final String method; + + /// Where the request goes. + final Uri uri; + + /// The header fields that go on the wire. + final FlutwireHeaders headers; + + /// The names of the header fields that were left out because they still hold + /// the mask. + final List hidden; + + /// The body, or null for none. + final Uint8List? body; +} + +/// The pieces of a `Cookie` header value, `a=1; b=2`, that do not hold [mask], +/// and whether any piece did. +({List live, bool dropped}) _splitCookies(String header, String mask) { + final live = []; + var dropped = false; + for (final raw in header.split(';')) { + final piece = raw.trim(); + if (piece.isEmpty) continue; + final eq = piece.indexOf('='); + final value = eq < 0 ? piece : piece.substring(eq + 1).trim(); + if (value == mask) { + dropped = true; + } else { + live.add(piece); + } + } + return (live: live, dropped: dropped); +} diff --git a/packages/flutwire/lib/src/runtime.dart b/packages/flutwire/lib/src/runtime.dart index c2d6c12..b3b4299 100644 --- a/packages/flutwire/lib/src/runtime.dart +++ b/packages/flutwire/lib/src/runtime.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'capture/wire/send_again.dart'; import 'capture/wire/wire.dart'; import 'config.dart'; import 'coverage.dart'; @@ -11,6 +12,7 @@ import 'isolate_bridge.dart'; import 'l10n/lookup.dart'; import 'notify/notifier.dart'; import 'platform/native.dart'; +import 'resend.dart'; import 'session.dart'; import 'share.dart'; import 'store/open_store.dart'; @@ -163,6 +165,12 @@ final class FlutwireRuntime implements FlutwireSession { ValueListenable> get coverage => engine.coverage.notes; + @override + bool get canResend => canSendAgain; + + @override + Future resend(FlutwireResend request) => sendAgain(engine, request); + @override ValueListenable get inspectorOpen => _launcher.isOpen; diff --git a/packages/flutwire/lib/src/session.dart b/packages/flutwire/lib/src/session.dart index b52c91a..e6f179e 100644 --- a/packages/flutwire/lib/src/session.dart +++ b/packages/flutwire/lib/src/session.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'config.dart'; import 'coverage.dart'; import 'platform/native.dart' show FlutwireNotificationPermission; +import 'resend.dart'; import 'share.dart'; import 'store/store.dart'; @@ -42,6 +43,16 @@ abstract interface class FlutwireSession { /// is wrong. ValueListenable> get coverage; + /// Whether a call can be sent again from the inspector. It cannot on the web, + /// where there is no `dart:io` to send it with. + bool get canResend; + + /// Sends [request] and records it as a call that repeats the one it names. + /// Returns the id of the recorded call as soon as the request is on its way, + /// or null when it could not be recorded. The call fills in as the answer + /// arrives. + Future resend(FlutwireResend request); + /// Whether the inspector is on screen. ValueListenable get inspectorOpen; diff --git a/packages/flutwire/lib/src/ui/detail/call_detail_page.dart b/packages/flutwire/lib/src/ui/detail/call_detail_page.dart index a20fe7e..78f66c8 100644 --- a/packages/flutwire/lib/src/ui/detail/call_detail_page.dart +++ b/packages/flutwire/lib/src/ui/detail/call_detail_page.dart @@ -9,7 +9,9 @@ import '../../model/headers.dart'; import '../../store/store.dart'; import '../../util/format.dart'; import '../actions.dart'; +import '../resend/resend_page.dart'; import '../scope.dart'; +import '../shell.dart'; import '../widgets/method_chip.dart'; import '../widgets/segmented_control.dart'; import '../widgets/status_pill.dart'; @@ -95,6 +97,18 @@ class _CallDetailPageState extends State { }); } + void _openCall(int id) { + unawaited( + Navigator.of(context).push( + shellRoute( + context.session, + name: 'flutwire/call', + child: CallDetailPage(callId: id), + ), + ), + ); + } + Future _togglePin(FlutwireCall call) async { await _store.setPinned([call.id], pinned: !call.pinned); await _load(); @@ -148,7 +162,24 @@ class _CallDetailPageState extends State { List _actions(BuildContext context, FlutwireCall call) { final s = context.strings; + final session = context.session; return [ + // A callable function travels through the native SDK, which adds + // credentials Flutwire never sees, so it cannot be sent again from here. + if (session.canResend && call.source != FlutwireSource.functions) + IconButton( + tooltip: s.resend, + icon: const Icon(Icons.replay_rounded), + onPressed: () => unawaited( + Navigator.of(context).push( + shellRoute( + session, + name: 'flutwire/resend', + child: ResendPage(call: call), + ), + ), + ), + ), IconButton( tooltip: call.pinned ? s.unpin : s.pin, icon: Icon( @@ -191,7 +222,7 @@ class _CallDetailPageState extends State { final s = context.strings; switch (_tab) { case _Tab.overview: - return OverviewTab(call: call); + return OverviewTab(call: call, onOpenCall: _openCall); case _Tab.request: final cookies = [ for (final line in call.request.headers['cookie']) diff --git a/packages/flutwire/lib/src/ui/detail/overview_tab.dart b/packages/flutwire/lib/src/ui/detail/overview_tab.dart index d6bfe62..d25a2cd 100644 --- a/packages/flutwire/lib/src/ui/detail/overview_tab.dart +++ b/packages/flutwire/lib/src/ui/detail/overview_tab.dart @@ -8,12 +8,29 @@ import '../scope.dart'; /// The facts of a call: what was asked, what came back, how long it took and /// anything that went wrong. class OverviewTab extends StatelessWidget { - /// Creates the tab for [call]. - const OverviewTab({super.key, required this.call}); + /// Creates the tab for [call]. [onOpenCall] opens another call of the + /// history, which the call it repeats is. + const OverviewTab({super.key, required this.call, this.onOpenCall}); /// The call to describe. final FlutwireCall call; + /// Opens the call with the given id. + final ValueChanged? onOpenCall; + + /// The line that names the call this one repeats, and opens it while it is + /// still in the history. + _Fact _repeated(BuildContext context, int from) { + final s = context.strings; + final original = context.session.store.find(from); + if (original == null) return _Fact(s.resentFrom, s.resentFromGone); + return _Fact( + s.resentFrom, + '${original.method} ${original.host}${original.pathAndQuery}', + onTap: onOpenCall == null ? null : () => onOpenCall!(from), + ); + } + @override Widget build(BuildContext context) { final s = context.strings; @@ -23,6 +40,7 @@ class OverviewTab extends StatelessWidget { final redirects = response?.redirects ?? const []; final request = <_Fact>[ + if (call.resentFrom case final from?) _repeated(context, from), _Fact(s.url, call.uri.toString()), _Fact(s.method, call.method), _Fact(s.started, formatDateTime(call.startedAt)), @@ -103,11 +121,14 @@ class OverviewTab extends StatelessWidget { } class _Fact { - const _Fact(this.label, this.value, {this.color}); + const _Fact(this.label, this.value, {this.color, this.onTap}); final String label; final String value; final Color? color; + + /// What a tap does. Without it a tap copies the value. + final VoidCallback? onTap; } class _Group extends StatelessWidget { @@ -150,7 +171,7 @@ class _Row extends StatelessWidget { Widget build(BuildContext context) { final t = context.fw; return InkWell( - onTap: () => copyText(context, fact.value), + onTap: fact.onTap ?? () => copyText(context, fact.value), borderRadius: last ? const BorderRadius.vertical(bottom: Radius.circular(14)) : null, diff --git a/packages/flutwire/lib/src/ui/resend/resend_page.dart b/packages/flutwire/lib/src/ui/resend/resend_page.dart new file mode 100644 index 0000000..f5d379f --- /dev/null +++ b/packages/flutwire/lib/src/ui/resend/resend_page.dart @@ -0,0 +1,601 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../model/body.dart'; +import '../../model/call.dart'; +import '../../resend.dart'; +import '../../util/format.dart'; +import '../actions.dart'; +import '../detail/call_detail_page.dart'; +import '../scope.dart'; +import '../shell.dart'; +import '../theme/tokens.dart'; +import '../widgets/method_chip.dart'; + +/// The methods the picker offers, besides the one the call used. +const List _methods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +]; + +/// How the body of the call is offered for editing. +enum _BodyMode { + /// Text, edited in place. An empty body is text with nothing in it. + text, + + /// Bytes that are not text, which can only be sent as they were recorded. + recorded, +} + +/// A header field is narrow, and its name is a long word in a monospaced face. +const EdgeInsets _fieldPadding = EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, +); + +class _HeaderRow { + _HeaderRow(String name, String value) + : name = TextEditingController(text: name), + value = TextEditingController(text: value); + + final TextEditingController name; + final TextEditingController value; + + void dispose() { + name.dispose(); + value.dispose(); + } +} + +/// A recorded call opened for changes, to be sent again. +/// +/// It starts as the call was: the same method, address, header fields and body. +/// A value the redaction hid is flagged and not sent, because Flutwire never +/// held it. What is sent is recorded as a new call that names this one. +class ResendPage extends StatefulWidget { + /// Opens [call], which must have its bodies loaded. + const ResendPage({super.key, required this.call}); + + /// The call to repeat. + final FlutwireCall call; + + @override + State createState() => _ResendPageState(); +} + +class _ResendPageState extends State { + late final TextEditingController _url; + late final TextEditingController _body; + late final _BodyMode _mode; + final List<_HeaderRow> _headers = []; + late String _method; + bool _sendRecordedBody = true; + bool _sending = false; + bool _triedToSend = false; + + FlutwireBody get _recorded => widget.call.request.body; + + @override + void initState() { + super.initState(); + final call = widget.call; + _method = call.method; + _url = TextEditingController(text: call.uri.toString()) + ..addListener(_changed); + _mode = _modeOf(_recorded); + _body = TextEditingController( + text: _mode == _BodyMode.text ? _recorded.text ?? '' : '', + ); + for (final field in call.request.headers.entries) { + if (clientManagedHeaders.contains(field.key.toLowerCase())) continue; + _headers.add(_row(field.key, field.value)); + } + } + + @override + void dispose() { + _url.dispose(); + _body.dispose(); + for (final row in _headers) { + row.dispose(); + } + super.dispose(); + } + + static _BodyMode _modeOf(FlutwireBody body) { + if (!body.loaded) return _BodyMode.recorded; + return switch (body.kind) { + FlutwireBodyKind.empty || + FlutwireBodyKind.json || + FlutwireBodyKind.xml || + FlutwireBodyKind.html || + FlutwireBodyKind.text || + FlutwireBodyKind.form => _BodyMode.text, + FlutwireBodyKind.multipart || + FlutwireBodyKind.image || + FlutwireBodyKind.binary => _BodyMode.recorded, + }; + } + + _HeaderRow _row(String name, String value) => _HeaderRow(name, value) + ..name.addListener(_changed) + ..value.addListener(_changed); + + void _changed() { + if (mounted) setState(() {}); + } + + void _addHeader() => setState(() => _headers.add(_row('', ''))); + + void _removeHeader(_HeaderRow row) { + setState(() => _headers.remove(row)); + row.dispose(); + } + + /// The address the editor holds, or null when it cannot be sent to. + Uri? get _target { + final uri = Uri.tryParse(_url.text.trim()); + if (uri == null || !uri.hasAuthority || uri.host.isEmpty) return null; + if (uri.scheme != 'http' && uri.scheme != 'https') return null; + return uri; + } + + /// Whether [text] still holds what the redaction left in place of a secret, + /// whether written out or percent-encoded in an address. + static bool _holdsMask(String text, String mask) { + if (mask.isEmpty) return false; + if (text.contains(mask)) return true; + try { + return Uri.decodeFull(text).contains(mask); + } on ArgumentError { + return false; + } + } + + bool get _canSendRecordedBody => + _mode == _BodyMode.recorded && _recorded.loaded && !_recorded.truncated; + + Uint8List? get _bodyBytes { + if (_mode == _BodyMode.text) { + final text = _body.text; + return text.isEmpty ? null : Uint8List.fromList(utf8.encode(text)); + } + if (_canSendRecordedBody && _sendRecordedBody && !_recorded.isEmpty) { + return _recorded.bytes; + } + return null; + } + + FlutwireResend _request(Uri uri, {required bool withBody}) => FlutwireResend( + from: widget.call.id, + method: _method, + uri: uri, + headers: [ + for (final row in _headers) MapEntry(row.name.text, row.value.text), + ], + mask: context.session.config.redaction.mask, + body: withBody ? _bodyBytes : null, + ); + + Future _send() async { + final target = _target; + if (target == null) { + setState(() => _triedToSend = true); + return; + } + final s = context.strings; + final session = context.session; + final navigator = Navigator.of(context); + final request = _request(target, withBody: true); + setState(() => _sending = true); + final int? id; + try { + id = await session.resend(request); + } on Object { + if (!mounted) return; + setState(() => _sending = false); + showToast(context, s.resendFailed); + return; + } + if (!mounted) return; + if (id == null) { + showToast(context, s.resendNotRecorded); + navigator.pop(); + return; + } + unawaited( + navigator.pushReplacement( + shellRoute( + session, + name: 'flutwire/call', + child: CallDetailPage(callId: id), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final s = context.strings; + final t = context.fw; + final target = _target; + final mask = context.session.config.redaction.mask; + // Only the header fields decide what is hidden, so the body, which can be + // large, is not read on every key. + final hidden = _request( + target ?? Uri.parse('https://invalid.invalid/'), + withBody: false, + ).hidden; + return Scaffold( + appBar: AppBar(title: Text(s.resend)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _RequestLine( + method: _method, + methods: {..._methods, widget.call.method}.toList(), + onMethod: (method) => setState(() => _method = method), + url: _url, + showError: _triedToSend && target == null, + ), + if (_holdsMask(_url.text, mask)) ...[ + const SizedBox(height: 8), + _Note( + icon: Icons.visibility_off_outlined, + text: s.resendUrlHidden(mask), + ), + ], + if (target != null) ...[ + const SizedBox(height: 8), + Text( + s.resendTarget(target.host), + style: t.body(size: 12, color: t.muted), + ), + ], + const SizedBox(height: 20), + _Heading(s.tabHeaders), + const SizedBox(height: 8), + for (final row in _headers) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _HeaderField( + row: row, + hidden: isHiddenValue( + row.name.text.trim(), + row.value.text, + mask, + ), + onRemove: () => _removeHeader(row), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _addHeader, + icon: const Icon(Icons.add_rounded, size: 18), + label: Text(s.addHeader), + ), + ), + if (hidden.isNotEmpty) ...[ + const SizedBox(height: 8), + _Note( + icon: Icons.visibility_off_outlined, + text: s.resendHiddenNote(hidden.join(', ')), + ), + ], + const SizedBox(height: 20), + _Heading(s.tabBody), + const SizedBox(height: 8), + ..._bodySection(context), + ], + ), + ), + ), + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + color: t.ground, + border: Border(top: BorderSide(color: t.line)), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Center( + heightFactor: 1, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _sending ? null : _send, + icon: _sending + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded, size: 18), + label: Text(_sending ? s.sending : s.send), + ), + ), + ), + ), + ), + ), + ), + ); + } + + List _bodySection(BuildContext context) { + final s = context.strings; + final t = context.fw; + if (_mode == _BodyMode.text) { + final mask = context.session.config.redaction.mask; + return [ + // Typing over the mask takes the note away, so it follows the text. + ListenableBuilder( + listenable: _body, + builder: (context, _) => _holdsMask(_body.text, mask) + ? Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _Note( + icon: Icons.visibility_off_outlined, + text: s.resendBodyHidden(mask), + ), + ) + : const SizedBox(width: double.infinity), + ), + if (_recorded.truncated) ...[ + _Note( + icon: Icons.content_cut_rounded, + text: s.resendBodyCut( + formatBytes(_recorded.bytes.length), + formatBytes(_recorded.totalBytes), + ), + ), + const SizedBox(height: 8), + ], + TextField( + controller: _body, + minLines: 6, + maxLines: 16, + keyboardType: TextInputType.multiline, + autocorrect: false, + enableSuggestions: false, + style: t.mono(size: 12.5, color: t.ink), + ), + ]; + } + final cut = !_canSendRecordedBody; + return [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: t.surface, + borderRadius: BorderRadius.circular(FlutwireTokens.radiusMd), + border: Border.all(color: t.line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cut + ? s.resendBinaryCut + : s.resendBinaryBody(formatBytes(_recorded.totalBytes)), + style: t.body(size: 13.5, color: t.ink), + ), + if (!cut) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + s.resendSendBody, + style: t.body(size: 13.5, color: t.muted), + ), + ), + Switch( + value: _sendRecordedBody, + onChanged: (value) => + setState(() => _sendRecordedBody = value), + ), + ], + ), + ], + ], + ), + ), + ]; + } +} + +class _Heading extends StatelessWidget { + const _Heading(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Text( + text.toUpperCase(), + style: context.fw.label(color: context.fw.faint), + ); +} + +class _RequestLine extends StatelessWidget { + const _RequestLine({ + required this.method, + required this.methods, + required this.onMethod, + required this.url, + required this.showError, + }); + + final String method; + final List methods; + final ValueChanged onMethod; + final TextEditingController url; + final bool showError; + + @override + Widget build(BuildContext context) { + final s = context.strings; + final t = context.fw; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PopupMenuButton( + tooltip: s.method, + initialValue: method, + onSelected: onMethod, + itemBuilder: (_) => [ + for (final m in methods) + PopupMenuItem(value: m, child: MethodChip(m)), + ], + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: t.surface, + borderRadius: BorderRadius.circular(FlutwireTokens.radiusMd), + border: Border.all(color: t.line), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MethodChip(method), + const SizedBox(width: 2), + Icon(Icons.arrow_drop_down_rounded, color: t.muted), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: url, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + style: t.mono(size: 12.5, color: t.ink), + decoration: InputDecoration( + hintText: s.url, + errorText: showError ? s.resendBadUrl : null, + errorMaxLines: 3, + ), + ), + ), + ], + ); + } +} + +class _HeaderField extends StatelessWidget { + const _HeaderField({ + required this.row, + required this.hidden, + required this.onRemove, + }); + + final _HeaderRow row; + final bool hidden; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final s = context.strings; + final t = context.fw; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: TextField( + controller: row.name, + autocorrect: false, + enableSuggestions: false, + style: t.mono(size: 12.5, color: t.ink), + decoration: InputDecoration( + hintText: s.headerName, + contentPadding: _fieldPadding, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 6, + child: TextField( + controller: row.value, + minLines: 1, + maxLines: 4, + autocorrect: false, + enableSuggestions: false, + style: t.mono(size: 12.5, color: hidden ? t.warning : t.ink), + decoration: InputDecoration( + hintText: s.headerValue, + contentPadding: _fieldPadding, + fillColor: hidden ? t.tint(t.warning) : null, + suffixIcon: hidden + ? Tooltip( + message: s.hiddenValue, + child: Icon( + Icons.visibility_off_outlined, + size: 18, + color: t.warning, + semanticLabel: s.hiddenValue, + ), + ) + : null, + ), + ), + ), + IconButton( + tooltip: s.removeHeader, + icon: const Icon(Icons.close_rounded), + onPressed: onRemove, + ), + ], + ); + } +} + +class _Note extends StatelessWidget { + const _Note({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + final t = context.fw; + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + color: t.warning.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(FlutwireTokens.radiusMd), + border: Border.all(color: t.warning.withValues(alpha: 0.35)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: t.warning), + const SizedBox(width: 10), + Expanded( + child: Text(text, style: t.body(size: 13, color: t.ink)), + ), + ], + ), + ); + } +} diff --git a/packages/flutwire/lib/src/ui/widgets/call_tile.dart b/packages/flutwire/lib/src/ui/widgets/call_tile.dart index 2e69996..468e911 100644 --- a/packages/flutwire/lib/src/ui/widgets/call_tile.dart +++ b/packages/flutwire/lib/src/ui/widgets/call_tile.dart @@ -102,6 +102,15 @@ class CallTile extends StatelessWidget { const SizedBox(width: 6), _Badge('GQL', t.copperTone), ], + if (call.resentFrom != null) ...[ + const SizedBox(width: 6), + Icon( + Icons.replay_rounded, + size: 13, + color: t.copperTone, + semanticLabel: context.strings.resentBadge, + ), + ], if (call.pinned) ...[ const SizedBox(width: 6), Icon( diff --git a/packages/flutwire/test/capture/send_again_test.dart b/packages/flutwire/test/capture/send_again_test.dart new file mode 100644 index 0000000..494f056 --- /dev/null +++ b/packages/flutwire/test/capture/send_again_test.dart @@ -0,0 +1,354 @@ +// These tests send real requests to a server on the loopback interface. They +// avoid the widget test binding, which replaces every HttpClient with one that +// refuses to connect. +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/src/capture/wire/send_again.dart'; +import 'package:flutwire/src/capture/wire/wire_io.dart'; +import 'package:flutwire/src/config.dart'; +import 'package:flutwire/src/engine.dart'; +import 'package:flutwire/src/model/call.dart'; +import 'package:flutwire/src/resend.dart'; +import 'package:flutwire/src/store/store.dart'; + +late HttpServer server; +late String base; + +Future handle(HttpRequest req) async { + try { + final path = req.uri.path; + if (path == '/echo') { + final body = await utf8.decodeStream(req); + final headers = >{}; + req.headers.forEach((name, values) => headers[name] = values); + req.response.headers.contentType = ContentType.json; + req.response.write( + jsonEncode({ + 'method': req.method, + 'uri': req.uri.toString(), + 'headers': headers, + 'body': body, + }), + ); + } else if (path == '/redirect') { + await req.response.redirect(Uri.parse('/echo')); + return; + } else if (path == '/hang') { + // Never answers. The test ends it. + return; + } else { + req.response.statusCode = 404; + } + await req.response.close(); + } on Object { + // The client walked away, which one of the tests does on purpose. + } +} + +/// App overrides that sit on top of whatever was installed before and chain to +/// it, the way a well behaved app's would. +class _Chaining extends HttpOverrides { + _Chaining(this._previous); + + final HttpOverrides? _previous; + int made = 0; + + @override + HttpClient createHttpClient(SecurityContext? context) { + made++; + final previous = _previous; + return previous != null + ? previous.createHttpClient(context) + : super.createHttpClient(context); + } +} + +Uint8List bytes(String text) => Uint8List.fromList(utf8.encode(text)); + +void main() { + setUpAll(() async { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen(handle); + base = 'http://127.0.0.1:${server.port}'; + }); + + tearDownAll(() => server.close(force: true)); + + late MemoryFlutwireStore store; + late FlutwireEngine engine; + + void start([FlutwireConfig config = const FlutwireConfig(enabled: true)]) { + store = MemoryFlutwireStore(notifyInterval: Duration.zero); + engine = FlutwireEngine(config: config, store: store); + } + + tearDown(() async { + HttpOverrides.global = null; + await engine.dispose(); + }); + + /// Waits until the call is over, however it ended. + Future settled(int id) async { + final end = DateTime.now().add(const Duration(seconds: 10)); + while (DateTime.now().isBefore(end)) { + final call = store.find(id); + if (call != null && !call.isPending) return call; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('The call $id did not finish.'); + } + + FlutwireResend request( + String path, { + String method = 'GET', + Map headers = const {}, + Uint8List? body, + int from = 7, + String mask = '[redacted]', + }) => FlutwireResend( + from: from, + method: method, + uri: Uri.parse('$base$path'), + headers: headers.entries, + mask: mask, + body: body, + ); + + Map echoed(FlutwireCall call) => + jsonDecode(call.response!.body.text!) as Map; + + group('sending a call again', () { + test('records a call that names the one it repeats', () async { + start(); + + final id = await sendAgain(engine, request('/echo?a=1')); + final call = await settled(id!); + + expect(call.resentFrom, 7); + expect(call.method, 'GET'); + expect(call.uri.path, '/echo'); + expect(call.state, FlutwireCallState.completed); + expect(call.statusCode, 200); + expect(echoed(call)['uri'], '/echo?a=1'); + }); + + test( + 'sends the method, the header fields and the body it was given', + () async { + start(); + + final id = await sendAgain( + engine, + request( + '/echo', + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'X-Trace': 'edited', + // A length from before the body was edited. + 'Content-Length': '999', + }, + body: bytes('{"qty":3}'), + ), + ); + final call = await settled(id!); + + final echo = echoed(call); + expect(echo['method'], 'POST'); + expect(echo['body'], '{"qty":3}'); + final headers = echo['headers']! as Map; + expect(headers['x-trace'], ['edited']); + expect(headers['content-type'], ['application/json']); + expect(headers['content-length'], ['9']); + expect(call.request.body.text, '{"qty":3}'); + }, + ); + + test('sends a value only once, when the field is repeated', () async { + start(); + + final id = await sendAgain( + engine, + FlutwireResend( + from: 1, + method: 'GET', + uri: Uri.parse('$base/echo'), + headers: const [ + MapEntry('User-Agent', 'first'), + MapEntry('X-Tag', 'a'), + MapEntry('X-Tag', 'b'), + ], + mask: '[redacted]', + ), + ); + final call = await settled(id!); + + final headers = echoed(call)['headers']! as Map; + expect(headers['user-agent'], ['first']); + expect(headers['x-tag'], ['a, b']); + }); + + test('leaves out what the redaction hid', () async { + start(); + + final id = await sendAgain( + engine, + request( + '/echo', + headers: { + 'Authorization': '[redacted]', + 'Cookie': 'session=[redacted]; theme=dark', + }, + ), + ); + final call = await settled(id!); + + final headers = echoed(call)['headers']! as Map; + expect(headers.containsKey('authorization'), isFalse); + expect(headers['cookie'], ['theme=dark']); + }); + + test('sends a value that was typed over the mask', () async { + start(); + + final id = await sendAgain( + engine, + request('/echo', headers: {'Authorization': 'Bearer typed'}), + ); + final call = await settled(id!); + + final headers = echoed(call)['headers']! as Map; + expect(headers['authorization'], ['Bearer typed']); + }); + + test('follows a redirect like the app would, and says so', () async { + start(); + + final id = await sendAgain(engine, request('/redirect')); + final call = await settled(id!); + + expect(call.statusCode, 200); + expect(call.response!.redirects, hasLength(1)); + expect(call.resentFrom, 7); + }); + + test('records a call that cannot connect as failed, with its id', () async { + start(); + final closed = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final port = closed.port; + await closed.close(force: true); + + final id = await sendAgain( + engine, + FlutwireResend( + from: 7, + method: 'GET', + uri: Uri.parse('http://127.0.0.1:$port/'), + headers: const [], + mask: '[redacted]', + ), + ); + final call = await settled(id!); + + expect(call.state, FlutwireCallState.failed); + expect(call.error!.type, 'SocketException'); + expect(call.resentFrom, 7); + }); + + test('ends a request nobody answers, as failed, at the deadline', () async { + start(); + + final id = await sendAgain( + engine, + request('/hang'), + deadline: const Duration(milliseconds: 200), + ); + final call = await settled(id!); + + expect(call.state, FlutwireCallState.failed); + expect(call.error!.type, 'TimeoutException'); + }); + }); + + group('the answer is wanted whatever else Flutwire is told', () { + test('records while recording is paused', () async { + start(); + engine.paused.value = true; + + final id = await sendAgain(engine, request('/echo')); + + expect((await settled(id!)).resentFrom, 7); + }); + + test('records a call to an ignored host', () async { + start( + FlutwireConfig( + enabled: true, + ignore: [FlutwireIgnore.host('127.0.0.1')], + ), + ); + + final id = await sendAgain(engine, request('/echo')); + + expect((await settled(id!)).resentFrom, 7); + }); + + test('answers null when Flutwire does not record in this build', () async { + start(const FlutwireConfig(enabled: false)); + + final id = await sendAgain(engine, request('/echo')); + + expect(id, isNull); + expect(store.calls.value, isEmpty); + }); + }); + + group('next to the capture', () { + test('a resent call is recorded once', () async { + start(); + installWireCapture(engine); + + final id = await sendAgain(engine, request('/echo')); + await settled(id!); + // Anything the capture recorded as well would be here by now. + await Future.delayed(const Duration(milliseconds: 100)); + + expect(store.calls.value, hasLength(1)); + expect(store.calls.value.single.resentFrom, 7); + }); + + test('also when the app chained its own overrides on top', () async { + start(); + installWireCapture(engine); + final app = _Chaining(HttpOverrides.current); + HttpOverrides.global = app; + + final id = await sendAgain(engine, request('/echo')); + await settled(id!); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(app.made, greaterThan(0)); + expect(store.calls.value, hasLength(1)); + expect(store.calls.value.single.resentFrom, 7); + }); + + test('the app\'s own calls are still recorded after a resend', () async { + start(); + installWireCapture(engine); + await settled((await sendAgain(engine, request('/echo')))!); + + final client = HttpClient(); + final req = await client.getUrl(Uri.parse('$base/echo')); + await (await req.close()).drain(); + client.close(); + await Future.delayed(const Duration(milliseconds: 100)); + + final calls = store.calls.value; + expect(calls, hasLength(2)); + expect(calls.where((c) => c.resentFrom == null), hasLength(1)); + }); + }); +} diff --git a/packages/flutwire/test/model/call_test.dart b/packages/flutwire/test/model/call_test.dart new file mode 100644 index 0000000..3aa12f6 --- /dev/null +++ b/packages/flutwire/test/model/call_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; + +FlutwireCall call({int? resentFrom}) => FlutwireCall( + id: 20, + method: 'POST', + uri: Uri.parse('https://api.test/items'), + source: FlutwireSource.io, + startedAt: DateTime.utc(2026, 10, 24, 12), + request: FlutwireRequest( + headers: FlutwireHeaders.empty, + body: FlutwireBody.empty(), + ), + resentFrom: resentFrom, +); + +void main() { + group('a call that repeats another', () { + test('names it in its summary and reads it back', () { + final json = call(resentFrom: 12).toJson(); + + expect(json['resentFrom'], 12); + expect(FlutwireCall.fromJson(json).resentFrom, 12); + }); + + test('keeps the link when it changes', () { + final changed = call(resentFrom: 12).copyWith(pinned: true); + + expect(changed.resentFrom, 12); + }); + + test('leaves the summary of an ordinary call as it was', () { + final json = call().toJson(); + + expect(json.containsKey('resentFrom'), isFalse); + expect(FlutwireCall.fromJson(json).resentFrom, isNull); + }); + }); +} diff --git a/packages/flutwire/test/resend_test.dart b/packages/flutwire/test/resend_test.dart new file mode 100644 index 0000000..3850e02 --- /dev/null +++ b/packages/flutwire/test/resend_test.dart @@ -0,0 +1,172 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/src/resend.dart'; + +FlutwireResend prepare( + Map headers, { + String mask = '[redacted]', + String method = 'GET', + Uint8List? body, +}) => FlutwireResend( + from: 1, + method: method, + uri: Uri.parse('https://api.test/items'), + headers: headers.entries, + mask: mask, + body: body, +); + +List sent(FlutwireResend request) => [ + for (final field in request.headers.entries) '${field.key}: ${field.value}', +]; + +void main() { + group('the header fields that are sent', () { + test('keep their order and the case they were written in', () { + final request = prepare({ + 'Accept': 'application/json', + 'X-Trace': 'abc', + 'accept-language': 'it', + }); + + expect(sent(request), [ + 'Accept: application/json', + 'X-Trace: abc', + 'accept-language: it', + ]); + }); + + test('leave out the ones the client writes itself', () { + final request = prepare({ + 'Host': 'api.test', + 'Content-Length': '12', + 'Transfer-Encoding': 'chunked', + 'Connection': 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Accept': '*/*', + }); + + expect(sent(request), ['Accept: */*']); + expect(request.hidden, isEmpty); + }); + + test('leave out a row with no name', () { + final request = prepare({' ': 'orphan', 'Accept': '*/*'}); + + expect(sent(request), ['Accept: */*']); + }); + }); + + group('what the redaction hid', () { + test('is not sent, and is named', () { + final request = prepare({ + 'Authorization': '[redacted]', + 'Proxy-Authorization': '[redacted]', + 'Accept': '*/*', + }); + + expect(sent(request), ['Accept: */*']); + expect(request.hidden, ['Authorization', 'Proxy-Authorization']); + }); + + test('is sent once somebody types a value over it', () { + final request = prepare({'Authorization': 'Bearer typed'}); + + expect(sent(request), ['Authorization: Bearer typed']); + expect(request.hidden, isEmpty); + }); + + test('follows the mask the app chose', () { + final request = prepare({ + 'Authorization': '***', + 'X-Other': '[redacted]', + }, mask: '***'); + + expect(sent(request), ['X-Other: [redacted]']); + expect(request.hidden, ['Authorization']); + }); + + test('is nothing when the mask is empty', () { + final request = prepare({'X-Empty': ''}, mask: ''); + + expect(sent(request), ['X-Empty: ']); + expect(request.hidden, isEmpty); + }); + + test('is dropped cookie by cookie, keeping the cookies that are real', () { + final request = prepare({ + 'Cookie': 'session=[redacted]; theme=dark; token=[redacted]', + }); + + expect(sent(request), ['Cookie: theme=dark']); + expect(request.hidden, ['Cookie']); + }); + + test('leaves out a cookie header with nothing real in it', () { + final request = prepare({'Cookie': 'a=[redacted]; b=[redacted]'}); + + expect(sent(request), isEmpty); + expect(request.hidden, ['Cookie']); + }); + + test('is dropped from a cookie piece that has no name', () { + final request = prepare({'Cookie': '[redacted]; theme=dark'}); + + expect(sent(request), ['Cookie: theme=dark']); + expect(request.hidden, ['Cookie']); + }); + + test('is named once however many fields hold it', () { + final request = prepare({'Cookie': 'a=[redacted]; b=[redacted]'}); + + expect(request.hidden, hasLength(1)); + }); + + test('keeps a real cookie header as it is', () { + final request = prepare({'cookie': 'a=1; b=2'}); + + expect(sent(request), ['cookie: a=1; b=2']); + expect(request.hidden, isEmpty); + }); + }); + + group('isHiddenValue', () { + test('matches a field by its whole value, whatever the name case', () { + expect( + isHiddenValue('Authorization', '[redacted]', '[redacted]'), + isTrue, + ); + expect(isHiddenValue('authorization', 'Bearer x', '[redacted]'), isFalse); + expect( + isHiddenValue('X-Note', 'it says [redacted] inside', '[redacted]'), + isFalse, + ); + }); + + test('matches a cookie header when one of its cookies is hidden', () { + expect( + isHiddenValue('Cookie', 'a=[redacted]; b=2', '[redacted]'), + isTrue, + ); + expect(isHiddenValue('cookie', 'a=1; b=2', '[redacted]'), isFalse); + }); + }); + + group('the request', () { + test('has its method in upper case', () { + expect(prepare({}, method: ' patch ').method, 'PATCH'); + }); + + test('sends no body for an empty one', () { + expect(prepare({}, body: Uint8List(0)).body, isNull); + expect(prepare({}).body, isNull); + }); + + test('keeps a body that has bytes', () { + final body = Uint8List.fromList([1, 2, 3]); + + expect(prepare({}, body: body).body, body); + }); + }); +} diff --git a/packages/flutwire/test/ui/calls.dart b/packages/flutwire/test/ui/calls.dart index 02dbeed..f25be27 100644 --- a/packages/flutwire/test/ui/calls.dart +++ b/packages/flutwire/test/ui/calls.dart @@ -27,6 +27,9 @@ FlutwireCall makeCall( FlutwireError? error, FlutwireGraphql? graphql, bool pinned = false, + int? resentFrom, + FlutwireSource source = FlutwireSource.io, + FlutwireBody? requestBodyOf, }) { final start = DateTime.utc(2026, 10, 23, 12); final pending = state == FlutwireCallState.pending; @@ -34,7 +37,7 @@ FlutwireCall makeCall( id: id, method: method, uri: Uri.parse(url), - source: FlutwireSource.io, + source: source, state: state, startedAt: start, firstByteAt: pending || status == null @@ -42,11 +45,14 @@ FlutwireCall makeCall( : start.add(Duration(milliseconds: millis ~/ 2)), endedAt: pending ? null : start.add(Duration(milliseconds: millis)), pinned: pinned, + resentFrom: resentFrom, error: error, graphql: graphql, request: FlutwireRequest( headers: FlutwireHeaders.fromMap(requestHeaders), - body: requestBody == null ? FlutwireBody.empty() : jsonBody(requestBody), + body: + requestBodyOf ?? + (requestBody == null ? FlutwireBody.empty() : jsonBody(requestBody)), ), response: status == null || pending ? null diff --git a/packages/flutwire/test/ui/fake_session.dart b/packages/flutwire/test/ui/fake_session.dart index 0fa0f98..71ac085 100644 --- a/packages/flutwire/test/ui/fake_session.dart +++ b/packages/flutwire/test/ui/fake_session.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutwire/flutwire.dart'; import 'package:flutwire/src/platform/native.dart'; +import 'package:flutwire/src/resend.dart'; import 'package:flutwire/src/session.dart'; import 'package:flutwire/src/share.dart'; @@ -71,6 +72,27 @@ class FakeSession implements FlutwireSession { const [], ); + /// Whether the screens may offer to send a call again. + @override + bool canResend = true; + + /// What the screens asked to send again, oldest first. + final List resent = []; + + /// The id `resend` answers with, as if that call had been recorded. + int? resendAnswer; + + /// What `resend` does besides remembering the request. A test makes it throw + /// to see what the screen does when sending fails. + Future Function(FlutwireResend request)? onResend; + + @override + Future resend(FlutwireResend request) async { + resent.add(request); + await onResend?.call(request); + return resendAnswer; + } + /// How many times the screens asked to open the inspector. int inspectorOpened = 0; diff --git a/packages/flutwire/test/ui/resend_entry_test.dart b/packages/flutwire/test/ui/resend_entry_test.dart new file mode 100644 index 0000000..87297ef --- /dev/null +++ b/packages/flutwire/test/ui/resend_entry_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/ui/detail/call_detail_page.dart'; +import 'package:flutwire/src/ui/widgets/call_tile.dart'; + +import 'calls.dart'; +import 'fake_session.dart'; +import 'harness.dart'; + +Future openDetail( + WidgetTester tester, + FlutwireCall call, { + List more = const [], + bool canResend = true, +}) async { + setScreen(tester, const Size(420, 900)); + final session = FakeSession(calls: [call, ...more])..canResend = canResend; + await tester.pumpWidget( + inspectorApp(session, CallDetailPage(callId: call.id)), + ); + await settle(tester); + return session; +} + +void main() { + group('the way into the editor', () { + testWidgets('is a button in the call, which opens the editor', ( + tester, + ) async { + await openDetail(tester, makeCall(1, method: 'POST')); + + await tester.tap(find.byTooltip('Edit and resend')); + await settle(tester); + + expect(find.text('Edit and resend'), findsOneWidget); + expect(find.text('Send'), findsOneWidget); + }); + + testWidgets('is not offered where nothing can be sent', (tester) async { + await openDetail(tester, makeCall(1), canResend: false); + + expect(find.byTooltip('Edit and resend'), findsNothing); + }); + + testWidgets('is not offered for a callable function', (tester) async { + await openDetail( + tester, + makeCall(1, source: FlutwireSource.functions, method: 'CALL'), + ); + + expect(find.byTooltip('Edit and resend'), findsNothing); + }); + }); + + group('a call that was sent again', () { + testWidgets('names the call it repeats, and opens it', (tester) async { + final original = makeCall( + 1, + method: 'POST', + url: 'https://api.test/orders?x=1', + ); + await openDetail( + tester, + makeCall( + 2, + method: 'POST', + url: 'https://staging.test/orders', + resentFrom: 1, + ), + more: [original], + ); + + expect(find.text('Sent again from'), findsOneWidget); + expect(find.text('POST api.test/orders?x=1'), findsOneWidget); + + await tester.tap(find.text('POST api.test/orders?x=1')); + await settle(tester); + + expect(find.text('POST api.test'), findsOneWidget); + }); + + testWidgets('says so when that call is gone from the history', ( + tester, + ) async { + await openDetail(tester, makeCall(2, resentFrom: 1)); + + expect(find.text('Sent again from'), findsOneWidget); + expect( + find.text('A call that is no longer in the history'), + findsOneWidget, + ); + }); + + testWidgets('has no such line when it repeats nothing', (tester) async { + await openDetail(tester, makeCall(2)); + + expect(find.text('Sent again from'), findsNothing); + }); + + testWidgets('is marked in the list', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpFlutwire( + tester, + Column( + children: [ + CallTile( + call: makeCall(2, resentFrom: 1), + now: DateTime.utc(2026, 10, 23, 12, 1), + ), + CallTile(call: makeCall(3), now: DateTime.utc(2026, 10, 23, 12, 1)), + ], + ), + ); + + expect(find.byIcon(Icons.replay_rounded), findsOneWidget); + expect(find.bySemanticsLabel(RegExp('Sent again')), findsOneWidget); + semantics.dispose(); + }); + }); +} diff --git a/packages/flutwire/test/ui/resend_page_test.dart b/packages/flutwire/test/ui/resend_page_test.dart new file mode 100644 index 0000000..4949f50 --- /dev/null +++ b/packages/flutwire/test/ui/resend_page_test.dart @@ -0,0 +1,431 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutwire/flutwire.dart'; +import 'package:flutwire/src/ui/resend/resend_page.dart'; +import 'package:flutwire/src/ui/shell.dart'; + +import 'calls.dart'; +import 'fake_session.dart'; + +/// A session whose history holds [call] and the call that sending it again +/// will have recorded, which is what `resend` answers with. +FakeSession sessionFor(FlutwireCall call, {FakeSession? session}) { + final fake = session ?? FakeSession(calls: [call, _answer(call.id)]); + fake.resendAnswer = 99; + return fake; +} + +FlutwireCall _answer(int from) => makeCall( + 99, + method: 'PUT', + url: 'https://sent.test/clubs', + resentFrom: from, +); + +/// Opens the editor on top of a page, so there is a route to go back to. +Future open( + WidgetTester tester, + FlutwireCall call, { + FakeSession? session, +}) async { + setScreen(tester, const Size(420, 900)); + final fake = sessionFor(call, session: session); + await tester.pumpWidget( + inspectorApp( + fake, + Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + shellRoute( + fake, + name: 'test', + child: ResendPage(call: call), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await settle(tester); + return fake; +} + +Finder field(String text) => find.widgetWithText(TextField, text); + +FlutwireCall orderCall({ + String method = 'POST', + String url = 'https://api.test/clubs?x=1', + Map headers = const {}, + String? body = '{"name":"Vega"}', + FlutwireBody? bodyOf, +}) => makeCall( + 5, + method: method, + url: url, + requestBody: body, + requestBodyOf: bodyOf, + requestHeaders: {'content-type': 'application/json', ...headers}, +); + +FlutwireBody binary(List bytes, {int? total}) => FlutwireBody( + bytes: Uint8List.fromList(bytes), + totalBytes: total, + contentType: 'application/octet-stream', +); + +void main() { + testWidgets('starts as the call was, without what the client writes', ( + tester, + ) async { + await open( + tester, + orderCall( + headers: {'x-trace': 'abc', 'host': 'api.test', 'content-length': '15'}, + ), + ); + + expect(find.text('Edit and resend'), findsOneWidget); + expect(field('https://api.test/clubs?x=1'), findsOneWidget); + expect(field('x-trace'), findsOneWidget); + expect(field('abc'), findsOneWidget); + expect(field('content-type'), findsOneWidget); + expect(field('host'), findsNothing); + expect(field('content-length'), findsNothing); + expect(field('{"name":"Vega"}'), findsOneWidget); + expect(find.text('Sends a real request to api.test.'), findsOneWidget); + }); + + testWidgets('sends what was changed, and opens the call it made', ( + tester, + ) async { + final session = await open(tester, orderCall(headers: {'x-trace': 'abc'})); + + await tester.enterText( + field('https://api.test/clubs?x=1'), + 'https://sent.test/clubs', + ); + await tester.enterText(field('abc'), 'edited'); + await tester.enterText(field('{"name":"Vega"}'), '{"name":"Lyra"}'); + await tester.tap(find.byType(PopupMenuButton)); + await settle(tester); + await tester.tap(find.text('PUT').last); + await settle(tester); + await tester.tap(find.text('Send')); + await settle(tester); + + final sent = session.resent.single; + expect(sent.from, 5); + expect(sent.method, 'PUT'); + expect(sent.uri.toString(), 'https://sent.test/clubs'); + expect(sent.headers.first('x-trace'), 'edited'); + expect(sent.headers.first('content-type'), 'application/json'); + expect(utf8.decode(sent.body!), '{"name":"Lyra"}'); + // The call that was made is open now, in place of the editor. + expect(find.text('PUT sent.test'), findsOneWidget); + expect(find.text('Send'), findsNothing); + }); + + testWidgets('adds a header field and takes one away', (tester) async { + final session = await open( + tester, + orderCall(headers: {'x-trace': 'abc', 'x-old': 'gone'}), + ); + + await tester.tap(find.text('Add header')); + await settle(tester); + // The new row is the last pair of fields: name, then value. + final names = find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Name', + ); + final values = find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Value', + ); + await tester.enterText(names.last, 'x-new'); + await tester.enterText(values.last, 'fresh'); + await tester.tap(find.byTooltip('Remove header').at(2)); + await settle(tester); + await tester.tap(find.text('Send')); + await settle(tester); + + final headers = session.resent.single.headers; + expect(headers.first('x-new'), 'fresh'); + expect(headers.first('x-trace'), 'abc'); + expect(headers.contains('x-old'), isFalse); + }); + + group('a value the redaction hid', () { + testWidgets('is flagged, named in a note, and not sent', (tester) async { + final session = await open( + tester, + orderCall(headers: {'authorization': '[redacted]', 'x-keep': '1'}), + ); + + expect( + find.textContaining('Not sent, because Flutwire hid their values'), + findsOneWidget, + ); + expect(find.byTooltip('Hidden'), findsOneWidget); + + await tester.tap(find.text('Send')); + await settle(tester); + + final sent = session.resent.single; + expect(sent.headers.contains('authorization'), isFalse); + expect(sent.headers.first('x-keep'), '1'); + expect(sent.hidden, ['authorization']); + }); + + testWidgets('is sent once a value is typed over it', (tester) async { + final session = await open( + tester, + orderCall(headers: {'authorization': '[redacted]'}), + ); + + await tester.enterText(field('[redacted]'), 'Bearer typed'); + await settle(tester); + + expect(find.textContaining('Not sent'), findsNothing); + expect(find.byTooltip('Hidden'), findsNothing); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect( + session.resent.single.headers.first('authorization'), + 'Bearer typed', + ); + }); + + testWidgets('is flagged in the address until it is replaced', ( + tester, + ) async { + await open( + tester, + orderCall(url: 'https://api.test/clubs?api_key=%5Bredacted%5D'), + ); + + expect( + find.textContaining('A value in the address was hidden'), + findsOneWidget, + ); + + await tester.enterText( + field('https://api.test/clubs?api_key=%5Bredacted%5D'), + 'https://api.test/clubs?api_key=typed', + ); + await settle(tester); + + expect(find.textContaining('A value in the address'), findsNothing); + }); + + testWidgets('is flagged in the body until it is replaced', (tester) async { + final session = await open( + tester, + orderCall(body: '{"user":"vega","password":"[redacted]"}'), + ); + + expect( + find.textContaining('Some values in the body were hidden'), + findsOneWidget, + ); + + await tester.enterText( + field('{"user":"vega","password":"[redacted]"}'), + '{"user":"vega","password":"typed"}', + ); + await settle(tester); + + expect(find.textContaining('Some values in the body'), findsNothing); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect( + utf8.decode(session.resent.single.body!), + '{"user":"vega","password":"typed"}', + ); + }); + + testWidgets('is flagged in a cookie that still holds the mask', ( + tester, + ) async { + final session = await open( + tester, + orderCall(headers: {'cookie': 'session=[redacted]; theme=dark'}), + ); + + expect(find.byTooltip('Hidden'), findsOneWidget); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect(session.resent.single.headers.first('cookie'), 'theme=dark'); + }); + }); + + testWidgets('does not send to an address it cannot reach', (tester) async { + final session = await open(tester, orderCall()); + + await tester.enterText(field('https://api.test/clubs?x=1'), 'not a url'); + await tester.tap(find.text('Send')); + await settle(tester); + + expect( + find.text('Enter a full address, such as https://example.com/path.'), + findsOneWidget, + ); + expect(session.resent, isEmpty); + + await tester.enterText(field('not a url'), 'ftp://files.test/a'); + await tester.tap(find.text('Send')); + await settle(tester); + + expect(session.resent, isEmpty); + }); + + group('the body', () { + testWidgets('of a call that had none is not sent', (tester) async { + final session = await open(tester, orderCall(method: 'GET', body: null)); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect(session.resent.single.body, isNull); + }); + + testWidgets('says when only the start of it was recorded', (tester) async { + await open( + tester, + orderCall( + bodyOf: FlutwireBody( + bytes: Uint8List.fromList(utf8.encode('{"a":')), + totalBytes: 5000, + contentType: 'application/json', + ), + ), + ); + + expect( + find.text( + 'The body was cut at 5 B of 4.9 KB. This is only the start of it.', + ), + findsOneWidget, + ); + expect(field('{"a":'), findsOneWidget); + }); + + testWidgets('that is not text goes out as it was recorded', (tester) async { + final session = await open( + tester, + orderCall(bodyOf: binary([1, 2, 3, 4])), + ); + + expect( + find.text( + 'A body that is not text, 4 B. It is sent as it was recorded.', + ), + findsOneWidget, + ); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect(session.resent.single.body, [1, 2, 3, 4]); + }); + + testWidgets('that is not text can be left out', (tester) async { + final session = await open( + tester, + orderCall(bodyOf: binary([1, 2, 3, 4])), + ); + + await tester.tap(find.byType(Switch)); + await settle(tester); + await tester.tap(find.text('Send')); + await settle(tester); + + expect(session.resent.single.body, isNull); + }); + + testWidgets('that is not text and was cut is not sent', (tester) async { + final session = await open( + tester, + orderCall(bodyOf: binary([1, 2], total: 100)), + ); + + expect( + find.text('Only part of this body was recorded, so it is not sent.'), + findsOneWidget, + ); + expect(find.byType(Switch), findsNothing); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect(session.resent.single.body, isNull); + }); + }); + + group('when sending does not go as planned', () { + testWidgets('a request that cannot leave says so and stays', ( + tester, + ) async { + final session = await open(tester, orderCall()); + session.onResend = (_) async => throw StateError('no route'); + + await tester.tap(find.text('Send')); + await settle(tester); + + expect(find.text('The request could not be sent.'), findsOneWidget); + expect(find.text('Edit and resend'), findsOneWidget); + expect(find.text('Send'), findsOneWidget); + }); + + testWidgets('a call that could not be recorded says so and goes back', ( + tester, + ) async { + final session = await open(tester, orderCall()); + session.resendAnswer = null; + + await tester.tap(find.text('Send')); + await settle(tester); + + expect( + find.text('Sent, but Flutwire could not record it.'), + findsOneWidget, + ); + expect(find.text('open'), findsOneWidget); + expect(find.text('Edit and resend'), findsNothing); + }); + }); + + testWidgets('speaks Italian when the app does', (tester) async { + final session = FakeSession( + config: const FlutwireConfig( + enabled: true, + captureDartIo: false, + persist: false, + shortcut: false, + locale: Locale('it'), + notification: FlutwireNotificationConfig(enabled: false), + ), + calls: [orderCall(), _answer(5)], + ); + + await open(tester, orderCall(), session: session); + + expect(find.text('Modifica e reinvia'), findsOneWidget); + expect(find.text('Invia'), findsOneWidget); + expect(find.text('Aggiungi header'), findsOneWidget); + expect(find.text('Invia una richiesta vera a api.test.'), findsOneWidget); + }); +}