feat(debug): 增加 HTTP 请求响应日志
This commit is contained in:
1 parent
dc4b60270d
commit
7a588898fa
6 files changed
+534
No files matched your search
@@ -0,0 +1,24 @@
|
|||||||
|
# Android Studio 调试 HTTP 日志
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 仅在调试构建中输出 Dio 请求、响应与异常日志。
|
||||||
|
- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。
|
||||||
|
- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。
|
||||||
|
- 单条请求体或响应体最多 3000 字符。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。
|
||||||
|
2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。
|
||||||
|
3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。
|
||||||
|
4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。
|
||||||
|
5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。
|
||||||
|
- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取,
|
||||||
|
避免拿到已解包后的 `data`。
|
||||||
|
- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。
|
||||||
|
- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。
|
||||||
@@ -531,3 +531,82 @@ $ flutter test --no-pub
|
|||||||
解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、
|
解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、
|
||||||
回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析
|
回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析
|
||||||
零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
|
零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||||
|
|
||||||
|
## 2026-10-22 Android Studio 调试 HTTP 日志
|
||||||
|
|
||||||
|
- 分支:`feature/debug-http-logging`
|
||||||
|
- 基线:`develop` / `origin/develop` = `dc4b602`
|
||||||
|
- 计划:`docs/plans/2026-10-22-debug-http-logging.md`
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git status --short --branch
|
||||||
|
## develop...origin/develop
|
||||||
|
M pubspec.lock
|
||||||
|
|
||||||
|
$ git rev-parse HEAD
|
||||||
|
dc4b60270dd38d594510b45bd7a3d50b7dcd2602
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:基于最新 develop 创建功能分支;开工时 `pubspec.lock` 已有未提交改动,
|
||||||
|
本次未修改、未暂存该文件。
|
||||||
|
|
||||||
|
### 实现结果
|
||||||
|
|
||||||
|
- 新增 `NetworkLogInterceptor`,默认 `debugPrint`,可注入
|
||||||
|
`void Function(String)` 作为单测日志收集器。
|
||||||
|
- `enabled` 常量与 `kDebugMode` 同时为真才输出,release/profile 零 HTTP 日志。
|
||||||
|
- 请求行包含完整 URL 与 query,同时输出耗时起点、脱敏请求头和格式化请求体。
|
||||||
|
- Authorization 仅保留前 8 字符后加 `...`;X-Device-Id 原样输出;
|
||||||
|
其余请求头值统一 `***`。
|
||||||
|
- ResponseType.bytes 响应统一 `utf8.decode(..., allowMalformed: true)`,
|
||||||
|
JSON 自动缩进;图片/音视频/字体/PDF/ZIP 等只输出 MIME 与字节数。
|
||||||
|
- 请求体和响应体超过 3000 字符时截断并追加 `…(truncated)`。
|
||||||
|
- 拦截链为 `AuthHeaderInterceptor → EnvelopeInterceptor → NetworkLogInterceptor`。
|
||||||
|
Envelope 在解包前暂存原始 bytes,末尾日志读取暂存值,因此仍能打印 Envelope
|
||||||
|
处理前的原始响应。
|
||||||
|
- 异常路径输出 `[HTTP] ✗ DioException类型 耗时ms URL message`,并继续
|
||||||
|
`handler.next(err)`,不吞异常。
|
||||||
|
|
||||||
|
代表性原始日志:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[HTTP] → POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
|
||||||
|
[HTTP] → 耗时起点:2026-10-22T12:12:25.377357
|
||||||
|
[HTTP] → headers:{"Accept":"***","Authorization":"Bearer e...","X-Device-Id":"device-raw-001"}
|
||||||
|
[HTTP] → body:{
|
||||||
|
"filter": "中文"
|
||||||
|
}
|
||||||
|
[HTTP] ← 200 12ms POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
|
||||||
|
[HTTP] ← body:{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"title": "中文标题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 门禁
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ flutter test --no-pub test/core/data/remote/network_log_interceptor_test.dart
|
||||||
|
00:00 +0: should_log_request_and_response_lines_with_elapsed_when_requested
|
||||||
|
00:00 +1: should_truncate_long_body_when_log_exceeds_limit
|
||||||
|
00:00 +2: should_log_plain_text_without_throwing_when_response_is_not_json
|
||||||
|
00:00 +3: should_log_byte_count_only_when_response_is_image
|
||||||
|
00:00 +4: should_log_dio_exception_type_and_message_when_request_fails
|
||||||
|
00:00 +5: All tests passed!
|
||||||
|
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:58 +622: All tests passed!
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:请求/响应行与耗时、Authorization 脱敏、X-Device-Id 原样、bytes 中文
|
||||||
|
UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 类型与 message
|
||||||
|
均通过;全量静态分析零告警,622 项测试全过。
|
||||||
|
|
||||||
|
未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../config/app_config.dart';
|
import '../../config/app_config.dart';
|
||||||
import 'api_exception.dart';
|
import 'api_exception.dart';
|
||||||
import 'api_interceptors.dart';
|
import 'api_interceptors.dart';
|
||||||
|
import 'network_log_interceptor.dart';
|
||||||
|
|
||||||
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
||||||
export 'api_exception.dart';
|
export 'api_exception.dart';
|
||||||
@@ -39,6 +42,7 @@ class ApiClient {
|
|||||||
deviceIdReader: deviceIdReader,
|
deviceIdReader: deviceIdReader,
|
||||||
),
|
),
|
||||||
const EnvelopeInterceptor(),
|
const EnvelopeInterceptor(),
|
||||||
|
NetworkLogInterceptor(),
|
||||||
]);
|
]);
|
||||||
if (adapter != null) {
|
if (adapter != null) {
|
||||||
_dio.httpClientAdapter = adapter;
|
_dio.httpClientAdapter = adapter;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import 'api_exception.dart';
|
import 'api_exception.dart';
|
||||||
|
import 'network_log_interceptor.dart';
|
||||||
|
|
||||||
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
||||||
class ApiRequestFlags {
|
class ApiRequestFlags {
|
||||||
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
|
|||||||
ResponseInterceptorHandler handler,
|
ResponseInterceptorHandler handler,
|
||||||
) {
|
) {
|
||||||
final options = response.requestOptions;
|
final options = response.requestOptions;
|
||||||
|
if (NetworkLogInterceptor.isEnabled) {
|
||||||
|
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
|
||||||
|
response.data;
|
||||||
|
}
|
||||||
final shell = _decodeShell(response.data);
|
final shell = _decodeShell(response.data);
|
||||||
final code = shell['code'] as int? ?? -1;
|
final code = shell['code'] as int? ?? -1;
|
||||||
final message = shell['message'] as String?;
|
final message = shell['message'] as String?;
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 调试构建下输出 Dio 请求、响应和异常详情。
|
||||||
|
class NetworkLogInterceptor extends Interceptor {
|
||||||
|
/// 在调试时关闭日志的本地常量开关。
|
||||||
|
static const bool enabled = true;
|
||||||
|
|
||||||
|
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
|
||||||
|
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
|
||||||
|
|
||||||
|
/// 请求、响应体单条日志最大字符数。
|
||||||
|
static const int maxBodyLength = 3000;
|
||||||
|
|
||||||
|
static const String _truncatedSuffix = '…(truncated)';
|
||||||
|
static const String _stopwatchKey = 'app.http.stopwatch';
|
||||||
|
static const String _startedAtKey = 'app.http.startedAt';
|
||||||
|
static const String _emptyBody = '<empty>';
|
||||||
|
static const String _redacted = '***';
|
||||||
|
|
||||||
|
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
|
||||||
|
NetworkLogInterceptor({void Function(String)? logger})
|
||||||
|
: _logger = logger ?? debugPrint;
|
||||||
|
|
||||||
|
final void Function(String) _logger;
|
||||||
|
|
||||||
|
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
|
||||||
|
static bool get isEnabled => kDebugMode && enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return handler.next(options);
|
||||||
|
}
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
options.extra[_stopwatchKey] = stopwatch;
|
||||||
|
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
|
||||||
|
final body = _formatRequestBody(options.data);
|
||||||
|
final message = StringBuffer()
|
||||||
|
..writeln('[HTTP] → ${options.method} ${options.uri}')
|
||||||
|
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
|
||||||
|
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
|
||||||
|
..writeln('[HTTP] → body:$body');
|
||||||
|
_logger(message.toString().trimRight());
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(
|
||||||
|
Response<dynamic> response,
|
||||||
|
ResponseInterceptorHandler handler,
|
||||||
|
) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return handler.next(response);
|
||||||
|
}
|
||||||
|
final options = response.requestOptions;
|
||||||
|
final elapsed = _elapsedMilliseconds(options);
|
||||||
|
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
|
||||||
|
final body = _formatResponseBody(
|
||||||
|
rawBody,
|
||||||
|
response.headers.value(Headers.contentTypeHeader),
|
||||||
|
);
|
||||||
|
final message = StringBuffer()
|
||||||
|
..writeln(
|
||||||
|
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
|
||||||
|
'${options.method} ${options.uri}',
|
||||||
|
)
|
||||||
|
..writeln('[HTTP] ← body:$body');
|
||||||
|
_logger(message.toString().trimRight());
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return handler.next(err);
|
||||||
|
}
|
||||||
|
final options = err.requestOptions;
|
||||||
|
final message = StringBuffer()
|
||||||
|
..writeln(
|
||||||
|
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
|
||||||
|
'${options.uri} ${err.message ?? ''}',
|
||||||
|
)
|
||||||
|
..write(_errorBodyLine(err));
|
||||||
|
_logger(message.toString().trimRight());
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _elapsedMilliseconds(RequestOptions options) {
|
||||||
|
final stopwatch = options.extra[_stopwatchKey];
|
||||||
|
if (stopwatch is Stopwatch) {
|
||||||
|
return stopwatch.elapsedMilliseconds;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _errorBodyLine(DioException error) {
|
||||||
|
final rawBody =
|
||||||
|
error.requestOptions.extra[rawResponseDataExtraKey] ??
|
||||||
|
error.response?.data;
|
||||||
|
if (rawBody == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
final body = _formatResponseBody(
|
||||||
|
rawBody,
|
||||||
|
error.response?.headers.value(Headers.contentTypeHeader),
|
||||||
|
);
|
||||||
|
return '\n[HTTP] ✗ body:$body';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatHeaders(Map<String, dynamic> headers) {
|
||||||
|
if (headers.isEmpty) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
final entries = headers.entries.toList()
|
||||||
|
..sort((left, right) => left.key.compareTo(right.key));
|
||||||
|
final safe = <String, String>{
|
||||||
|
for (final entry in entries)
|
||||||
|
entry.key: _safeHeaderValue(entry.key, entry.value),
|
||||||
|
};
|
||||||
|
return _truncate(jsonEncode(safe));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _safeHeaderValue(String name, Object? value) {
|
||||||
|
final text = value?.toString() ?? '';
|
||||||
|
if (name.toLowerCase() == 'authorization') {
|
||||||
|
return _maskAuthorization(text);
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() == 'x-device-id') {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return _redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _maskAuthorization(String value) {
|
||||||
|
if (value.length <= 8) {
|
||||||
|
return _redacted;
|
||||||
|
}
|
||||||
|
return '${value.substring(0, math.min(8, value.length))}...';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRequestBody(Object? body) {
|
||||||
|
if (body == null) {
|
||||||
|
return _emptyBody;
|
||||||
|
}
|
||||||
|
final json = _tryPrettyJson(body);
|
||||||
|
if (json != null) {
|
||||||
|
return _truncate(json);
|
||||||
|
}
|
||||||
|
final length = switch (body) {
|
||||||
|
FormData data => data.length,
|
||||||
|
List<int> bytes => bytes.length,
|
||||||
|
_ => utf8.encode(body.toString()).length,
|
||||||
|
};
|
||||||
|
return '${body.runtimeType} ($length bytes)';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatResponseBody(Object? body, String? contentType) {
|
||||||
|
if (body == null) {
|
||||||
|
return _emptyBody;
|
||||||
|
}
|
||||||
|
if (body is List<int>) {
|
||||||
|
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
|
||||||
|
if (_isBinary(contentType)) {
|
||||||
|
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
|
||||||
|
}
|
||||||
|
final text = utf8.decode(bytes, allowMalformed: true);
|
||||||
|
return _formatText(text);
|
||||||
|
}
|
||||||
|
return _formatText(body.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatText(String text) {
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return _emptyBody;
|
||||||
|
}
|
||||||
|
return _truncate(_tryPrettyJson(text) ?? text);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _tryPrettyJson(Object? body) {
|
||||||
|
try {
|
||||||
|
final value = body is String
|
||||||
|
? jsonDecode(body)
|
||||||
|
: body is List<int>
|
||||||
|
? jsonDecode(utf8.decode(body, allowMalformed: true))
|
||||||
|
: body;
|
||||||
|
if (value is! Map && value is! List) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(value);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isBinary(String? contentType) {
|
||||||
|
final type = contentType?.toLowerCase() ?? '';
|
||||||
|
return type.startsWith('image/') ||
|
||||||
|
type.startsWith('audio/') ||
|
||||||
|
type.startsWith('video/') ||
|
||||||
|
type.startsWith('font/') ||
|
||||||
|
type.contains('application/octet-stream') ||
|
||||||
|
type.contains('application/pdf') ||
|
||||||
|
type.contains('application/zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _truncate(String value) {
|
||||||
|
if (value.length <= maxBodyLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
final end = maxBodyLength - _truncatedSuffix.length;
|
||||||
|
return '${value.substring(0, end)}$_truncatedSuffix';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/remote/api_interceptors.dart';
|
||||||
|
import 'package:fruitshorts/core/data/remote/network_log_interceptor.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const baseUrl = 'http://logging.test';
|
||||||
|
const token = 'eyJhbGciOiJIUzM4NCJ9.secret';
|
||||||
|
|
||||||
|
Dio buildDio(
|
||||||
|
List<String> logs,
|
||||||
|
HttpClientAdapter adapter, {
|
||||||
|
bool useEnvelope = true,
|
||||||
|
}) {
|
||||||
|
final dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dio.httpClientAdapter = adapter;
|
||||||
|
dio.interceptors.addAll([
|
||||||
|
AuthHeaderInterceptor(
|
||||||
|
accessTokenReader: () => token,
|
||||||
|
deviceIdReader: () async => 'device-raw-001',
|
||||||
|
),
|
||||||
|
if (useEnvelope) const EnvelopeInterceptor(),
|
||||||
|
NetworkLogInterceptor(logger: logs.add),
|
||||||
|
]);
|
||||||
|
return dio;
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_log_request_and_response_lines_with_elapsed_when_requested',
|
||||||
|
() async {
|
||||||
|
final logs = <String>[];
|
||||||
|
final dio = buildDio(
|
||||||
|
logs,
|
||||||
|
_RawHttpAdapter.fromText(
|
||||||
|
jsonEncode({
|
||||||
|
'code': 0,
|
||||||
|
'message': 'ok',
|
||||||
|
'data': {'title': '中文标题'},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dio.post<void>(
|
||||||
|
'/app/logging',
|
||||||
|
queryParameters: {'keyword': '中文'},
|
||||||
|
data: {'filter': '中文'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
logs.first,
|
||||||
|
contains('[HTTP] → POST $baseUrl/app/logging?keyword='),
|
||||||
|
);
|
||||||
|
expect(logs.join('\n'), contains('[HTTP] → 耗时起点:'));
|
||||||
|
expect(logs.join('\n'), matches(RegExp(r'\[HTTP\] ← 200 \d+ms POST ')));
|
||||||
|
expect(logs.join('\n'), contains('"filter": "中文"'));
|
||||||
|
expect(logs.join('\n'), contains('"中文标题"'));
|
||||||
|
expect(logs.join('\n'), contains('"Authorization":"Bearer e..."'));
|
||||||
|
expect(logs.join('\n'), contains('"X-Device-Id":"device-raw-001"'));
|
||||||
|
expect(logs.join('\n'), isNot(contains(token)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('should_truncate_long_body_when_log_exceeds_limit', () async {
|
||||||
|
final logs = <String>[];
|
||||||
|
final dio = buildDio(
|
||||||
|
logs,
|
||||||
|
_RawHttpAdapter.fromText(
|
||||||
|
jsonEncode({
|
||||||
|
'code': 0,
|
||||||
|
'message': 'ok',
|
||||||
|
'data': {'text': List.filled(3500, '长').join()},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dio.get<void>('/app/long');
|
||||||
|
|
||||||
|
final responseLog = logs.singleWhere(
|
||||||
|
(line) => line.startsWith('[HTTP] ← 200'),
|
||||||
|
);
|
||||||
|
const prefix = '[HTTP] ← body:';
|
||||||
|
final body = responseLog.substring(
|
||||||
|
responseLog.indexOf(prefix) + prefix.length,
|
||||||
|
);
|
||||||
|
expect(body.length, NetworkLogInterceptor.maxBodyLength);
|
||||||
|
expect(body, endsWith('…(truncated)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_log_plain_text_without_throwing_when_response_is_not_json',
|
||||||
|
() async {
|
||||||
|
final logs = <String>[];
|
||||||
|
final dio = buildDio(
|
||||||
|
logs,
|
||||||
|
_RawHttpAdapter.fromText('plain text response'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
dio.get<void>('/app/plain'),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logs.join('\n'), contains('[HTTP] ✗'));
|
||||||
|
expect(logs.join('\n'), contains('plain text response'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('should_log_byte_count_only_when_response_is_image', () async {
|
||||||
|
final logs = <String>[];
|
||||||
|
final dio = buildDio(
|
||||||
|
logs,
|
||||||
|
_RawHttpAdapter(
|
||||||
|
Uint8List.fromList([0xff, 0xd8, 0xff, 0x00]),
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
),
|
||||||
|
useEnvelope: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await dio.get<void>('/files/image.jpg');
|
||||||
|
|
||||||
|
expect(logs.join('\n'), contains('<binary image/jpeg, 4 bytes>'));
|
||||||
|
expect(logs.join('\n'), isNot(contains('�')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_log_dio_exception_type_and_message_when_request_fails',
|
||||||
|
() async {
|
||||||
|
final logs = <String>[];
|
||||||
|
final dio = buildDio(logs, _ThrowingHttpAdapter());
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
dio.get<void>('/app/offline'),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
logs.join('\n'),
|
||||||
|
matches(RegExp(r'\[HTTP\] ✗ connectionError \d+ms .* offline')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RawHttpAdapter implements HttpClientAdapter {
|
||||||
|
_RawHttpAdapter(this.bytes, {required this.contentType});
|
||||||
|
|
||||||
|
factory _RawHttpAdapter.fromText(String body) {
|
||||||
|
return _RawHttpAdapter(
|
||||||
|
Uint8List.fromList(utf8.encode(body)),
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String contentType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResponseBody> fetch(
|
||||||
|
RequestOptions options,
|
||||||
|
Stream<Uint8List>? requestStream,
|
||||||
|
Future<void>? cancelFuture,
|
||||||
|
) async {
|
||||||
|
return ResponseBody.fromBytes(
|
||||||
|
bytes,
|
||||||
|
200,
|
||||||
|
headers: {
|
||||||
|
Headers.contentTypeHeader: [contentType],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThrowingHttpAdapter implements HttpClientAdapter {
|
||||||
|
@override
|
||||||
|
Future<ResponseBody> fetch(
|
||||||
|
RequestOptions options,
|
||||||
|
Stream<Uint8List>? requestStream,
|
||||||
|
Future<void>? cancelFuture,
|
||||||
|
) {
|
||||||
|
throw DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.connectionError,
|
||||||
|
message: 'offline',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user