mirror of
https://github.com/flutter/packages.git
synced 2025-06-26 12:06:29 +08:00
[metric_center] Migrate code to null safety (#439)
This commit is contained in:
@ -1,3 +1,7 @@
|
|||||||
|
# 1.0.0
|
||||||
|
|
||||||
|
- Null safety support
|
||||||
|
|
||||||
# 0.1.1
|
# 0.1.1
|
||||||
|
|
||||||
- Update packages to null safe
|
- Update packages to null safe
|
||||||
|
@ -16,11 +16,11 @@ class MetricPoint extends Equatable {
|
|||||||
/// Creates a new data point.
|
/// Creates a new data point.
|
||||||
MetricPoint(
|
MetricPoint(
|
||||||
this.value,
|
this.value,
|
||||||
Map<String, String> tags,
|
Map<String, String?> tags,
|
||||||
) : _tags = SplayTreeMap<String, String>.from(tags);
|
) : _tags = SplayTreeMap<String, String>.from(tags);
|
||||||
|
|
||||||
/// Can store integer values.
|
/// Can store integer values.
|
||||||
final double value;
|
final double? value;
|
||||||
|
|
||||||
/// Test name, unit, timestamp, configs, git revision, ..., in sorted order.
|
/// Test name, unit, timestamp, configs, git revision, ..., in sorted order.
|
||||||
UnmodifiableMapView<String, String> get tags =>
|
UnmodifiableMapView<String, String> get tags =>
|
||||||
@ -43,7 +43,7 @@ class MetricPoint extends Equatable {
|
|||||||
final SplayTreeMap<String, String> _tags;
|
final SplayTreeMap<String, String> _tags;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[value, tags];
|
List<Object?> get props => <Object?>[value, tags];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interface to write [MetricPoint].
|
/// Interface to write [MetricPoint].
|
||||||
|
@ -82,7 +82,7 @@ class GcsLock {
|
|||||||
await _api.objects.delete(_bucketName, lockFileName);
|
await _api.objects.delete(_bucketName, lockFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
StorageApi _api;
|
late StorageApi _api;
|
||||||
|
|
||||||
final String _bucketName;
|
final String _bucketName;
|
||||||
final AuthClient _client;
|
final AuthClient _client;
|
||||||
|
@ -50,21 +50,21 @@ void _parseAnItem(
|
|||||||
};
|
};
|
||||||
for (final String subResult in item.keys) {
|
for (final String subResult in item.keys) {
|
||||||
if (!_kNonNumericalValueSubResults.contains(subResult)) {
|
if (!_kNonNumericalValueSubResults.contains(subResult)) {
|
||||||
num rawValue;
|
num? rawValue;
|
||||||
try {
|
try {
|
||||||
rawValue = item[subResult] as num;
|
rawValue = item[subResult] as num?;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(
|
print(
|
||||||
'$subResult: ${item[subResult]} (${item[subResult].runtimeType}) is not a number');
|
'$subResult: ${item[subResult]} (${item[subResult].runtimeType}) is not a number');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
final double value =
|
final double? value =
|
||||||
rawValue is int ? rawValue.toDouble() : rawValue as double;
|
rawValue is int ? rawValue.toDouble() : rawValue as double?;
|
||||||
points.add(
|
points.add(
|
||||||
MetricPoint(
|
MetricPoint(
|
||||||
value,
|
value,
|
||||||
<String, String>{kNameKey: name, kSubResultKey: subResult}
|
<String, String?>{kNameKey: name, kSubResultKey: subResult}
|
||||||
..addAll(context)
|
..addAll(context)
|
||||||
..addAll(
|
..addAll(
|
||||||
subResult.endsWith('time') ? timeUnitMap : <String, String>{}),
|
subResult.endsWith('time') ? timeUnitMap : <String, String>{}),
|
||||||
|
@ -48,15 +48,15 @@ import 'gcs_lock.dart';
|
|||||||
/// ```
|
/// ```
|
||||||
class SkiaPerfPoint extends MetricPoint {
|
class SkiaPerfPoint extends MetricPoint {
|
||||||
SkiaPerfPoint._(this.githubRepo, this.gitHash, this.testName, this.subResult,
|
SkiaPerfPoint._(this.githubRepo, this.gitHash, this.testName, this.subResult,
|
||||||
double value, this._options, this.jsonUrl)
|
double? value, this._options, this.jsonUrl)
|
||||||
: assert(_options[kGithubRepoKey] == null),
|
: assert(_options[kGithubRepoKey] == null),
|
||||||
assert(_options[kGitRevisionKey] == null),
|
assert(_options[kGitRevisionKey] == null),
|
||||||
assert(_options[kNameKey] == null),
|
assert(_options[kNameKey] == null),
|
||||||
super(
|
super(
|
||||||
value,
|
value,
|
||||||
<String, String>{}
|
<String, String?>{}
|
||||||
..addAll(_options)
|
..addAll(_options)
|
||||||
..addAll(<String, String>{
|
..addAll(<String, String?>{
|
||||||
kGithubRepoKey: githubRepo,
|
kGithubRepoKey: githubRepo,
|
||||||
kGitRevisionKey: gitHash,
|
kGitRevisionKey: gitHash,
|
||||||
kNameKey: testName,
|
kNameKey: testName,
|
||||||
@ -78,9 +78,9 @@ class SkiaPerfPoint extends MetricPoint {
|
|||||||
/// Skia perf will use the git revision's date instead of this date tag in
|
/// Skia perf will use the git revision's date instead of this date tag in
|
||||||
/// the time axis.
|
/// the time axis.
|
||||||
factory SkiaPerfPoint.fromPoint(MetricPoint p) {
|
factory SkiaPerfPoint.fromPoint(MetricPoint p) {
|
||||||
final String githubRepo = p.tags[kGithubRepoKey];
|
final String? githubRepo = p.tags[kGithubRepoKey];
|
||||||
final String gitHash = p.tags[kGitRevisionKey];
|
final String? gitHash = p.tags[kGitRevisionKey];
|
||||||
final String name = p.tags[kNameKey];
|
final String? name = p.tags[kNameKey];
|
||||||
|
|
||||||
if (githubRepo == null || gitHash == null || name == null) {
|
if (githubRepo == null || gitHash == null || name == null) {
|
||||||
throw '$kGithubRepoKey, $kGitRevisionKey, $kNameKey must be set in'
|
throw '$kGithubRepoKey, $kGitRevisionKey, $kNameKey must be set in'
|
||||||
@ -113,7 +113,7 @@ class SkiaPerfPoint extends MetricPoint {
|
|||||||
final String githubRepo;
|
final String githubRepo;
|
||||||
|
|
||||||
/// SHA such as 'ad20d368ffa09559754e4b2b5c12951341ca3b2d'
|
/// SHA such as 'ad20d368ffa09559754e4b2b5c12951341ca3b2d'
|
||||||
final String gitHash;
|
final String? gitHash;
|
||||||
|
|
||||||
/// For Flutter devicelab, this is the task name (e.g.,
|
/// For Flutter devicelab, this is the task name (e.g.,
|
||||||
/// 'flutter_gallery__transition_perf'); for Google benchmark, this is the
|
/// 'flutter_gallery__transition_perf'); for Google benchmark, this is the
|
||||||
@ -138,7 +138,7 @@ class SkiaPerfPoint extends MetricPoint {
|
|||||||
/// The url to the Skia perf json file in the Google Cloud Storage bucket.
|
/// The url to the Skia perf json file in the Google Cloud Storage bucket.
|
||||||
///
|
///
|
||||||
/// This can be null if the point has been stored in the bucket yet.
|
/// This can be null if the point has been stored in the bucket yet.
|
||||||
final String jsonUrl;
|
final String? jsonUrl;
|
||||||
|
|
||||||
Map<String, dynamic> _toSubResultJson() {
|
Map<String, dynamic> _toSubResultJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@ -260,7 +260,7 @@ class SkiaPerfGcsAdaptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SkiaPerfPoint>> _readPointsWithoutRetry(String objectName) async {
|
Future<List<SkiaPerfPoint>> _readPointsWithoutRetry(String objectName) async {
|
||||||
ObjectInfo info;
|
ObjectInfo? info;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
info = await _gcsBucket.info(objectName);
|
info = await _gcsBucket.info(objectName);
|
||||||
@ -282,10 +282,10 @@ class SkiaPerfGcsAdaptor {
|
|||||||
|
|
||||||
final String firstGcsNameComponent = objectName.split('/')[0];
|
final String firstGcsNameComponent = objectName.split('/')[0];
|
||||||
_populateGcsNameToGithubRepoMapIfNeeded();
|
_populateGcsNameToGithubRepoMapIfNeeded();
|
||||||
final String githubRepo = _gcsNameToGithubRepo[firstGcsNameComponent];
|
final String githubRepo = _gcsNameToGithubRepo[firstGcsNameComponent]!;
|
||||||
assert(githubRepo != null);
|
assert(githubRepo != null);
|
||||||
|
|
||||||
final String gitHash = decodedJson[kSkiaPerfGitHashKey] as String;
|
final String? gitHash = decodedJson[kSkiaPerfGitHashKey] as String?;
|
||||||
final Map<String, dynamic> results =
|
final Map<String, dynamic> results =
|
||||||
decodedJson[kSkiaPerfResultsKey] as Map<String, dynamic>;
|
decodedJson[kSkiaPerfResultsKey] as Map<String, dynamic>;
|
||||||
for (final String name in results.keys) {
|
for (final String name in results.keys) {
|
||||||
@ -298,7 +298,7 @@ class SkiaPerfGcsAdaptor {
|
|||||||
gitHash,
|
gitHash,
|
||||||
name,
|
name,
|
||||||
subResult,
|
subResult,
|
||||||
subResultMap[subResult] as double,
|
subResultMap[subResult] as double?,
|
||||||
(subResultMap[kSkiaPerfOptionsKey] as Map<String, dynamic>)
|
(subResultMap[kSkiaPerfOptionsKey] as Map<String, dynamic>)
|
||||||
.cast<String, String>(),
|
.cast<String, String>(),
|
||||||
info.downloadLink.toString(),
|
info.downloadLink.toString(),
|
||||||
@ -317,9 +317,9 @@ class SkiaPerfGcsAdaptor {
|
|||||||
/// json files in the future to scale up the system if too many writes are
|
/// json files in the future to scale up the system if too many writes are
|
||||||
/// competing for the same json file.
|
/// competing for the same json file.
|
||||||
static Future<String> computeObjectName(
|
static Future<String> computeObjectName(
|
||||||
String githubRepo, String revision, DateTime commitTime) async {
|
String githubRepo, String? revision, DateTime commitTime) async {
|
||||||
assert(_githubRepoToGcsName[githubRepo] != null);
|
assert(_githubRepoToGcsName[githubRepo] != null);
|
||||||
final String topComponent = _githubRepoToGcsName[githubRepo];
|
final String? topComponent = _githubRepoToGcsName[githubRepo];
|
||||||
// [commitTime] is not guranteed to be UTC. Ensure it is so all results
|
// [commitTime] is not guranteed to be UTC. Ensure it is so all results
|
||||||
// pushed to GCS are the same timezone.
|
// pushed to GCS are the same timezone.
|
||||||
final DateTime commitUtcTime = commitTime.toUtc();
|
final DateTime commitUtcTime = commitTime.toUtc();
|
||||||
@ -334,12 +334,12 @@ class SkiaPerfGcsAdaptor {
|
|||||||
kFlutterFrameworkRepo: 'flutter-flutter',
|
kFlutterFrameworkRepo: 'flutter-flutter',
|
||||||
kFlutterEngineRepo: 'flutter-engine',
|
kFlutterEngineRepo: 'flutter-engine',
|
||||||
};
|
};
|
||||||
static final Map<String, String> _gcsNameToGithubRepo = <String, String>{};
|
static final Map<String?, String> _gcsNameToGithubRepo = <String?, String>{};
|
||||||
|
|
||||||
static void _populateGcsNameToGithubRepoMapIfNeeded() {
|
static void _populateGcsNameToGithubRepoMapIfNeeded() {
|
||||||
if (_gcsNameToGithubRepo.isEmpty) {
|
if (_gcsNameToGithubRepo.isEmpty) {
|
||||||
for (final String repo in _githubRepoToGcsName.keys) {
|
for (final String repo in _githubRepoToGcsName.keys) {
|
||||||
final String gcsName = _githubRepoToGcsName[repo];
|
final String? gcsName = _githubRepoToGcsName[repo];
|
||||||
assert(_gcsNameToGithubRepo[gcsName] == null);
|
assert(_gcsNameToGithubRepo[gcsName] == null);
|
||||||
_gcsNameToGithubRepo[gcsName] = repo;
|
_gcsNameToGithubRepo[gcsName] = repo;
|
||||||
}
|
}
|
||||||
@ -396,43 +396,43 @@ class SkiaPerfDestination extends MetricDestination {
|
|||||||
// 1st, create a map based on git repo, git revision, and point id. Git repo
|
// 1st, create a map based on git repo, git revision, and point id. Git repo
|
||||||
// and git revision are the top level components of the Skia perf GCS object
|
// and git revision are the top level components of the Skia perf GCS object
|
||||||
// name.
|
// name.
|
||||||
final Map<String, Map<String, Map<String, SkiaPerfPoint>>> pointMap =
|
final Map<String, Map<String?, Map<String, SkiaPerfPoint>>> pointMap =
|
||||||
<String, Map<String, Map<String, SkiaPerfPoint>>>{};
|
<String, Map<String, Map<String, SkiaPerfPoint>>>{};
|
||||||
for (final SkiaPerfPoint p
|
for (final SkiaPerfPoint p
|
||||||
in points.map((MetricPoint x) => SkiaPerfPoint.fromPoint(x))) {
|
in points.map((MetricPoint x) => SkiaPerfPoint.fromPoint(x))) {
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
pointMap[p.githubRepo] ??= <String, Map<String, SkiaPerfPoint>>{};
|
pointMap[p.githubRepo] ??= <String, Map<String, SkiaPerfPoint>>{};
|
||||||
pointMap[p.githubRepo][p.gitHash] ??= <String, SkiaPerfPoint>{};
|
pointMap[p.githubRepo]![p.gitHash] ??= <String, SkiaPerfPoint>{};
|
||||||
pointMap[p.githubRepo][p.gitHash][p.id] = p;
|
pointMap[p.githubRepo]![p.gitHash]![p.id] = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2nd, read existing points from the gcs object and update with new ones.
|
// 2nd, read existing points from the gcs object and update with new ones.
|
||||||
for (final String repo in pointMap.keys) {
|
for (final String repo in pointMap.keys) {
|
||||||
for (final String revision in pointMap[repo].keys) {
|
for (final String? revision in pointMap[repo]!.keys) {
|
||||||
final String objectName = await SkiaPerfGcsAdaptor.computeObjectName(
|
final String objectName = await SkiaPerfGcsAdaptor.computeObjectName(
|
||||||
repo, revision, commitTime);
|
repo, revision, commitTime);
|
||||||
final Map<String, SkiaPerfPoint> newPoints = pointMap[repo][revision];
|
final Map<String, SkiaPerfPoint>? newPoints = pointMap[repo]![revision];
|
||||||
// If too many bots are writing the metrics of a git revision into this
|
// If too many bots are writing the metrics of a git revision into this
|
||||||
// single json file (with name `objectName`), the contention on the lock
|
// single json file (with name `objectName`), the contention on the lock
|
||||||
// might be too high. In that case, break the json file into multiple
|
// might be too high. In that case, break the json file into multiple
|
||||||
// json files according to bot names or task names. Skia perf read all
|
// json files according to bot names or task names. Skia perf read all
|
||||||
// json files in the directory so one can use arbitrary names for those
|
// json files in the directory so one can use arbitrary names for those
|
||||||
// sharded json file names.
|
// sharded json file names.
|
||||||
_lock.protectedRun('$objectName.lock', () async {
|
_lock!.protectedRun('$objectName.lock', () async {
|
||||||
final List<SkiaPerfPoint> oldPoints =
|
final List<SkiaPerfPoint> oldPoints =
|
||||||
await _gcs.readPoints(objectName);
|
await _gcs.readPoints(objectName);
|
||||||
for (final SkiaPerfPoint p in oldPoints) {
|
for (final SkiaPerfPoint p in oldPoints) {
|
||||||
if (newPoints[p.id] == null) {
|
if (newPoints![p.id] == null) {
|
||||||
newPoints[p.id] = p;
|
newPoints[p.id] = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _gcs.writePoints(objectName, newPoints.values.toList());
|
await _gcs.writePoints(objectName, newPoints!.values.toList());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final SkiaPerfGcsAdaptor _gcs;
|
final SkiaPerfGcsAdaptor _gcs;
|
||||||
final GcsLock _lock;
|
late final GcsLock? _lock;
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
name: metrics_center
|
name: metrics_center
|
||||||
version: 0.1.1
|
version: 1.0.0
|
||||||
description:
|
description:
|
||||||
Support multiple performance metrics sources/formats and destinations.
|
Support multiple performance metrics sources/formats and destinations.
|
||||||
repository: https://github.com/flutter/packages/tree/master/packages/metrics_center
|
repository: https://github.com/flutter/packages/tree/master/packages/metrics_center
|
||||||
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+metrics_center%22
|
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+metrics_center%22
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.10.0 <3.0.0'
|
sdk: '>=2.12.0 <3.0.0'
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
crypto: ^3.0.0
|
crypto: ^3.0.1
|
||||||
equatable: ^1.2.5
|
equatable: ^2.0.3
|
||||||
gcloud: ^0.8.0
|
gcloud: ^0.8.2
|
||||||
googleapis: ^3.0.0
|
googleapis: ^3.0.0
|
||||||
googleapis_auth: ^1.0.0
|
googleapis_auth: ^1.1.0
|
||||||
http: ^0.13.3
|
http: ^0.13.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
build_runner: ^2.1.1
|
||||||
fake_async: ^1.2.0
|
fake_async: ^1.2.0
|
||||||
mockito: ^5.0.0
|
mockito: ^5.0.14
|
||||||
pedantic: ^1.10.0
|
pedantic: ^1.11.1
|
||||||
test: ^1.17.0
|
test: ^1.17.11
|
||||||
|
@ -38,11 +38,11 @@ void main() {
|
|||||||
expect(detailedPoint.tags[kUnitKey], equals('ns'));
|
expect(detailedPoint.tags[kUnitKey], equals('ns'));
|
||||||
});
|
});
|
||||||
|
|
||||||
final Map<String, dynamic> credentialsJson = getTestGcpCredentialsJson();
|
final Map<String, dynamic>? credentialsJson = getTestGcpCredentialsJson();
|
||||||
|
|
||||||
test('FlutterDestination integration test with update.', () async {
|
test('FlutterDestination integration test with update.', () async {
|
||||||
final FlutterDestination dst =
|
final FlutterDestination dst =
|
||||||
await FlutterDestination.makeFromCredentialsJson(credentialsJson,
|
await FlutterDestination.makeFromCredentialsJson(credentialsJson!,
|
||||||
isTesting: true);
|
isTesting: true);
|
||||||
dst.update(<FlutterEngineMetricPoint>[simplePoint],
|
dst.update(<FlutterEngineMetricPoint>[simplePoint],
|
||||||
DateTime.fromMillisecondsSinceEpoch(123));
|
DateTime.fromMillisecondsSinceEpoch(123));
|
||||||
|
@ -10,9 +10,11 @@ import 'package:googleapis/storage/v1.dart';
|
|||||||
import 'package:googleapis_auth/auth_io.dart';
|
import 'package:googleapis_auth/auth_io.dart';
|
||||||
import 'package:metrics_center/src/constants.dart';
|
import 'package:metrics_center/src/constants.dart';
|
||||||
import 'package:metrics_center/src/gcs_lock.dart';
|
import 'package:metrics_center/src/gcs_lock.dart';
|
||||||
|
import 'package:mockito/annotations.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
|
|
||||||
import 'common.dart';
|
import 'common.dart';
|
||||||
|
import 'gcs_lock_test.mocks.dart';
|
||||||
import 'utility.dart';
|
import 'utility.dart';
|
||||||
|
|
||||||
enum TestPhase {
|
enum TestPhase {
|
||||||
@ -20,11 +22,10 @@ enum TestPhase {
|
|||||||
run2,
|
run2,
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockClient extends Mock implements AuthClient {}
|
@GenerateMocks(<Type>[AuthClient])
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
const Duration kDelayStep = Duration(milliseconds: 10);
|
const Duration kDelayStep = Duration(milliseconds: 10);
|
||||||
final Map<String, dynamic> credentialsJson = getTestGcpCredentialsJson();
|
final Map<String, dynamic>? credentialsJson = getTestGcpCredentialsJson();
|
||||||
|
|
||||||
test('GcsLock prints warnings for long waits', () {
|
test('GcsLock prints warnings for long waits', () {
|
||||||
// Capture print to verify error messages.
|
// Capture print to verify error messages.
|
||||||
@ -34,7 +35,7 @@ void main() {
|
|||||||
|
|
||||||
Zone.current.fork(specification: spec).run<void>(() {
|
Zone.current.fork(specification: spec).run<void>(() {
|
||||||
fakeAsync((FakeAsync fakeAsync) {
|
fakeAsync((FakeAsync fakeAsync) {
|
||||||
final MockClient mockClient = MockClient();
|
final MockAuthClient mockClient = MockAuthClient();
|
||||||
final GcsLock lock = GcsLock(mockClient, 'mockBucket');
|
final GcsLock lock = GcsLock(mockClient, 'mockBucket');
|
||||||
when(mockClient.send(any)).thenThrow(DetailedApiRequestError(412, ''));
|
when(mockClient.send(any)).thenThrow(DetailedApiRequestError(412, ''));
|
||||||
final Future<void> runFinished =
|
final Future<void> runFinished =
|
||||||
|
117
packages/metrics_center/test/gcs_lock_test.mocks.dart
Normal file
117
packages/metrics_center/test/gcs_lock_test.mocks.dart
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// Mocks generated by Mockito 5.0.14 from annotations
|
||||||
|
// in metrics_center/test/gcs_lock_test.dart.
|
||||||
|
// Do not manually edit this file.
|
||||||
|
|
||||||
|
import 'dart:async' as _i5;
|
||||||
|
import 'dart:convert' as _i6;
|
||||||
|
import 'dart:typed_data' as _i7;
|
||||||
|
|
||||||
|
import 'package:googleapis_auth/src/access_credentials.dart' as _i2;
|
||||||
|
import 'package:googleapis_auth/src/auth_client.dart' as _i4;
|
||||||
|
import 'package:http/http.dart' as _i3;
|
||||||
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
|
|
||||||
|
// ignore_for_file: always_specify_types
|
||||||
|
// ignore_for_file: avoid_redundant_argument_values
|
||||||
|
// ignore_for_file: avoid_setters_without_getters
|
||||||
|
// ignore_for_file: camel_case_types
|
||||||
|
// ignore_for_file: comment_references
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||||
|
// ignore_for_file: prefer_const_constructors
|
||||||
|
// ignore_for_file: unnecessary_overrides
|
||||||
|
// ignore_for_file: unnecessary_parenthesis
|
||||||
|
|
||||||
|
class _FakeAccessCredentials_0 extends _i1.Fake
|
||||||
|
implements _i2.AccessCredentials {}
|
||||||
|
|
||||||
|
class _FakeResponse_1 extends _i1.Fake implements _i3.Response {}
|
||||||
|
|
||||||
|
class _FakeStreamedResponse_2 extends _i1.Fake implements _i3.StreamedResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A class which mocks [AuthClient].
|
||||||
|
///
|
||||||
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
|
class MockAuthClient extends _i1.Mock implements _i4.AuthClient {
|
||||||
|
MockAuthClient() {
|
||||||
|
_i1.throwOnMissingStub(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i2.AccessCredentials get credentials =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#credentials),
|
||||||
|
returnValue: _FakeAccessCredentials_0()) as _i2.AccessCredentials);
|
||||||
|
@override
|
||||||
|
String toString() => super.toString();
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.Response> head(Uri? url, {Map<String, String>? headers}) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#head, [url], {#headers: headers}),
|
||||||
|
returnValue: Future<_i3.Response>.value(_FakeResponse_1()))
|
||||||
|
as _i5.Future<_i3.Response>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.Response> get(Uri? url, {Map<String, String>? headers}) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#get, [url], {#headers: headers}),
|
||||||
|
returnValue: Future<_i3.Response>.value(_FakeResponse_1()))
|
||||||
|
as _i5.Future<_i3.Response>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.Response> post(Uri? url,
|
||||||
|
{Map<String, String>? headers,
|
||||||
|
Object? body,
|
||||||
|
_i6.Encoding? encoding}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#post, [url],
|
||||||
|
{#headers: headers, #body: body, #encoding: encoding}),
|
||||||
|
returnValue: Future<_i3.Response>.value(_FakeResponse_1()))
|
||||||
|
as _i5.Future<_i3.Response>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.Response> put(Uri? url,
|
||||||
|
{Map<String, String>? headers,
|
||||||
|
Object? body,
|
||||||
|
_i6.Encoding? encoding}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#put, [url],
|
||||||
|
{#headers: headers, #body: body, #encoding: encoding}),
|
||||||
|
returnValue: Future<_i3.Response>.value(_FakeResponse_1()))
|
||||||
|
as _i5.Future<_i3.Response>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.Response> patch(Uri? url,
|
||||||
|
{Map<String, String>? headers,
|
||||||
|
Object? body,
|
||||||
|
_i6.Encoding? encoding}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#patch, [url],
|
||||||
|
{#headers: headers, #body: body, #encoding: encoding}),
|
||||||
|
returnValue: Future<_i3.Response>.value(_FakeResponse_1()))
|
||||||
|
as _i5.Future<_i3.Response>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.Response> delete(Uri? url,
|
||||||
|
{Map<String, String>? headers,
|
||||||
|
Object? body,
|
||||||
|
_i6.Encoding? encoding}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#delete, [url],
|
||||||
|
{#headers: headers, #body: body, #encoding: encoding}),
|
||||||
|
returnValue: Future<_i3.Response>.value(_FakeResponse_1()))
|
||||||
|
as _i5.Future<_i3.Response>);
|
||||||
|
@override
|
||||||
|
_i5.Future<String> read(Uri? url, {Map<String, String>? headers}) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#read, [url], {#headers: headers}),
|
||||||
|
returnValue: Future<String>.value('')) as _i5.Future<String>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i7.Uint8List> readBytes(Uri? url,
|
||||||
|
{Map<String, String>? headers}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#readBytes, [url], {#headers: headers}),
|
||||||
|
returnValue: Future<_i7.Uint8List>.value(_i7.Uint8List(0)))
|
||||||
|
as _i5.Future<_i7.Uint8List>);
|
||||||
|
@override
|
||||||
|
_i5.Future<_i3.StreamedResponse> send(_i3.BaseRequest? request) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#send, [request]),
|
||||||
|
returnValue:
|
||||||
|
Future<_i3.StreamedResponse>.value(_FakeStreamedResponse_2()))
|
||||||
|
as _i5.Future<_i3.StreamedResponse>);
|
||||||
|
@override
|
||||||
|
void close() => super.noSuchMethod(Invocation.method(#close, []),
|
||||||
|
returnValueForMissingStub: null);
|
||||||
|
}
|
@ -12,15 +12,13 @@ import 'package:googleapis_auth/auth_io.dart';
|
|||||||
import 'package:metrics_center/metrics_center.dart';
|
import 'package:metrics_center/metrics_center.dart';
|
||||||
import 'package:metrics_center/src/constants.dart';
|
import 'package:metrics_center/src/constants.dart';
|
||||||
import 'package:metrics_center/src/gcs_lock.dart';
|
import 'package:metrics_center/src/gcs_lock.dart';
|
||||||
|
import 'package:mockito/annotations.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
|
|
||||||
import 'common.dart';
|
import 'common.dart';
|
||||||
|
import 'skiaperf_test.mocks.dart';
|
||||||
import 'utility.dart';
|
import 'utility.dart';
|
||||||
|
|
||||||
class MockBucket extends Mock implements Bucket {}
|
|
||||||
|
|
||||||
class MockObjectInfo extends Mock implements ObjectInfo {}
|
|
||||||
|
|
||||||
class MockGcsLock implements GcsLock {
|
class MockGcsLock implements GcsLock {
|
||||||
@override
|
@override
|
||||||
Future<void> protectedRun(
|
Future<void> protectedRun(
|
||||||
@ -46,6 +44,7 @@ class MockSkiaPerfGcsAdaptor implements SkiaPerfGcsAdaptor {
|
|||||||
<String, List<SkiaPerfPoint>>{};
|
<String, List<SkiaPerfPoint>>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateMocks(<Type>[Bucket, ObjectInfo])
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
const double kValue1 = 1.0;
|
const double kValue1 = 1.0;
|
||||||
const double kValue2 = 2.0;
|
const double kValue2 = 2.0;
|
||||||
@ -353,6 +352,8 @@ Future<void> main() async {
|
|||||||
];
|
];
|
||||||
final String skiaPerfJson =
|
final String skiaPerfJson =
|
||||||
jsonEncode(SkiaPerfPoint.toSkiaPerfJson(writePoints));
|
jsonEncode(SkiaPerfPoint.toSkiaPerfJson(writePoints));
|
||||||
|
when(testBucket.writeBytes(testObjectName, utf8.encode(skiaPerfJson)))
|
||||||
|
.thenAnswer((_) async => FakeObjectInfo());
|
||||||
await skiaPerfGcs.writePoints(testObjectName, writePoints);
|
await skiaPerfGcs.writePoints(testObjectName, writePoints);
|
||||||
verify(testBucket.writeBytes(testObjectName, utf8.encode(skiaPerfJson)));
|
verify(testBucket.writeBytes(testObjectName, utf8.encode(skiaPerfJson)));
|
||||||
|
|
||||||
@ -392,9 +393,9 @@ Future<void> main() async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The following is for integration tests.
|
// The following is for integration tests.
|
||||||
Bucket testBucket;
|
Bucket? testBucket;
|
||||||
GcsLock testLock;
|
GcsLock? testLock;
|
||||||
final Map<String, dynamic> credentialsJson = getTestGcpCredentialsJson();
|
final Map<String, dynamic>? credentialsJson = getTestGcpCredentialsJson();
|
||||||
if (credentialsJson != null) {
|
if (credentialsJson != null) {
|
||||||
final ServiceAccountCredentials credentials =
|
final ServiceAccountCredentials credentials =
|
||||||
ServiceAccountCredentials.fromJson(credentialsJson);
|
ServiceAccountCredentials.fromJson(credentialsJson);
|
||||||
@ -412,7 +413,7 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skiaPerfGcsAdapterIntegrationTest() async {
|
Future<void> skiaPerfGcsAdapterIntegrationTest() async {
|
||||||
final SkiaPerfGcsAdaptor skiaPerfGcs = SkiaPerfGcsAdaptor(testBucket);
|
final SkiaPerfGcsAdaptor skiaPerfGcs = SkiaPerfGcsAdaptor(testBucket!);
|
||||||
|
|
||||||
final String testObjectName = await SkiaPerfGcsAdaptor.computeObjectName(
|
final String testObjectName = await SkiaPerfGcsAdaptor.computeObjectName(
|
||||||
kFlutterFrameworkRepo,
|
kFlutterFrameworkRepo,
|
||||||
@ -443,7 +444,7 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skiaPerfGcsIntegrationTestWithEnginePoints() async {
|
Future<void> skiaPerfGcsIntegrationTestWithEnginePoints() async {
|
||||||
final SkiaPerfGcsAdaptor skiaPerfGcs = SkiaPerfGcsAdaptor(testBucket);
|
final SkiaPerfGcsAdaptor skiaPerfGcs = SkiaPerfGcsAdaptor(testBucket!);
|
||||||
|
|
||||||
final String testObjectName = await SkiaPerfGcsAdaptor.computeObjectName(
|
final String testObjectName = await SkiaPerfGcsAdaptor.computeObjectName(
|
||||||
kFlutterEngineRepo,
|
kFlutterEngineRepo,
|
||||||
@ -569,7 +570,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
Future<void> skiaPerfDestinationIntegrationTest() async {
|
Future<void> skiaPerfDestinationIntegrationTest() async {
|
||||||
final SkiaPerfDestination destination =
|
final SkiaPerfDestination destination =
|
||||||
SkiaPerfDestination(SkiaPerfGcsAdaptor(testBucket), testLock);
|
SkiaPerfDestination(SkiaPerfGcsAdaptor(testBucket!), testLock);
|
||||||
await destination.update(<MetricPoint>[cocoonPointRev1Metric1],
|
await destination.update(<MetricPoint>[cocoonPointRev1Metric1],
|
||||||
DateTime.fromMillisecondsSinceEpoch(123));
|
DateTime.fromMillisecondsSinceEpoch(123));
|
||||||
}
|
}
|
||||||
@ -580,3 +581,32 @@ Future<void> main() async {
|
|||||||
skip: testBucket == null,
|
skip: testBucket == null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeObjectInfo extends ObjectInfo {
|
||||||
|
@override
|
||||||
|
int get crc32CChecksum => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Uri get downloadLink => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get etag => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ObjectGeneration get generation => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get length => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<int> get md5Hash => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ObjectMetadata get metadata => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
DateTime get updated => throw UnimplementedError();
|
||||||
|
}
|
||||||
|
175
packages/metrics_center/test/skiaperf_test.mocks.dart
Normal file
175
packages/metrics_center/test/skiaperf_test.mocks.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
// Mocks generated by Mockito 5.0.14 from annotations
|
||||||
|
// in metrics_center/test/skiaperf_test.dart.
|
||||||
|
// Do not manually edit this file.
|
||||||
|
|
||||||
|
import 'dart:async' as _i2;
|
||||||
|
|
||||||
|
import 'package:gcloud/common.dart' as _i4;
|
||||||
|
import 'package:gcloud/storage.dart' as _i3;
|
||||||
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
|
|
||||||
|
// ignore_for_file: always_specify_types
|
||||||
|
// ignore_for_file: avoid_redundant_argument_values
|
||||||
|
// ignore_for_file: avoid_setters_without_getters
|
||||||
|
// ignore_for_file: camel_case_types
|
||||||
|
// ignore_for_file: comment_references
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||||
|
// ignore_for_file: prefer_const_constructors
|
||||||
|
// ignore_for_file: unnecessary_overrides
|
||||||
|
// ignore_for_file: unnecessary_parenthesis
|
||||||
|
|
||||||
|
class _FakeStreamSink_0<S> extends _i1.Fake implements _i2.StreamSink<S> {}
|
||||||
|
|
||||||
|
class _FakeObjectInfo_1 extends _i1.Fake implements _i3.ObjectInfo {}
|
||||||
|
|
||||||
|
class _FakePage_2<T> extends _i1.Fake implements _i4.Page<T> {}
|
||||||
|
|
||||||
|
class _FakeDateTime_3 extends _i1.Fake implements DateTime {}
|
||||||
|
|
||||||
|
class _FakeUri_4 extends _i1.Fake implements Uri {}
|
||||||
|
|
||||||
|
class _FakeObjectGeneration_5 extends _i1.Fake implements _i3.ObjectGeneration {
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeObjectMetadata_6 extends _i1.Fake implements _i3.ObjectMetadata {}
|
||||||
|
|
||||||
|
/// A class which mocks [Bucket].
|
||||||
|
///
|
||||||
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
|
class MockBucket extends _i1.Mock implements _i3.Bucket {
|
||||||
|
MockBucket() {
|
||||||
|
_i1.throwOnMissingStub(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bucketName =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#bucketName), returnValue: '')
|
||||||
|
as String);
|
||||||
|
@override
|
||||||
|
String absoluteObjectName(String? objectName) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#absoluteObjectName, [objectName]),
|
||||||
|
returnValue: '') as String);
|
||||||
|
@override
|
||||||
|
_i2.StreamSink<List<int>> write(String? objectName,
|
||||||
|
{int? length,
|
||||||
|
_i3.ObjectMetadata? metadata,
|
||||||
|
_i3.Acl? acl,
|
||||||
|
_i3.PredefinedAcl? predefinedAcl,
|
||||||
|
String? contentType}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#write, [
|
||||||
|
objectName
|
||||||
|
], {
|
||||||
|
#length: length,
|
||||||
|
#metadata: metadata,
|
||||||
|
#acl: acl,
|
||||||
|
#predefinedAcl: predefinedAcl,
|
||||||
|
#contentType: contentType
|
||||||
|
}),
|
||||||
|
returnValue: _FakeStreamSink_0<List<int>>())
|
||||||
|
as _i2.StreamSink<List<int>>);
|
||||||
|
@override
|
||||||
|
_i2.Future<_i3.ObjectInfo> writeBytes(String? name, List<int>? bytes,
|
||||||
|
{_i3.ObjectMetadata? metadata,
|
||||||
|
_i3.Acl? acl,
|
||||||
|
_i3.PredefinedAcl? predefinedAcl,
|
||||||
|
String? contentType}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#writeBytes, [
|
||||||
|
name,
|
||||||
|
bytes
|
||||||
|
], {
|
||||||
|
#metadata: metadata,
|
||||||
|
#acl: acl,
|
||||||
|
#predefinedAcl: predefinedAcl,
|
||||||
|
#contentType: contentType
|
||||||
|
}),
|
||||||
|
returnValue: Future<_i3.ObjectInfo>.value(_FakeObjectInfo_1()))
|
||||||
|
as _i2.Future<_i3.ObjectInfo>);
|
||||||
|
@override
|
||||||
|
_i2.Stream<List<int>> read(String? objectName, {int? offset, int? length}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#read, [objectName], {#offset: offset, #length: length}),
|
||||||
|
returnValue: Stream<List<int>>.empty()) as _i2.Stream<List<int>>);
|
||||||
|
@override
|
||||||
|
_i2.Future<_i3.ObjectInfo> info(String? name) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#info, [name]),
|
||||||
|
returnValue: Future<_i3.ObjectInfo>.value(_FakeObjectInfo_1()))
|
||||||
|
as _i2.Future<_i3.ObjectInfo>);
|
||||||
|
@override
|
||||||
|
_i2.Future<dynamic> delete(String? name) =>
|
||||||
|
(super.noSuchMethod(Invocation.method(#delete, [name]),
|
||||||
|
returnValue: Future<dynamic>.value()) as _i2.Future<dynamic>);
|
||||||
|
@override
|
||||||
|
_i2.Future<dynamic> updateMetadata(
|
||||||
|
String? objectName, _i3.ObjectMetadata? metadata) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#updateMetadata, [objectName, metadata]),
|
||||||
|
returnValue: Future<dynamic>.value()) as _i2.Future<dynamic>);
|
||||||
|
@override
|
||||||
|
_i2.Stream<_i3.BucketEntry> list({String? prefix, String? delimiter}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#list, [], {#prefix: prefix, #delimiter: delimiter}),
|
||||||
|
returnValue: Stream<_i3.BucketEntry>.empty())
|
||||||
|
as _i2.Stream<_i3.BucketEntry>);
|
||||||
|
@override
|
||||||
|
_i2.Future<_i4.Page<_i3.BucketEntry>> page(
|
||||||
|
{String? prefix, String? delimiter, int? pageSize = 50}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(#page, [], {
|
||||||
|
#prefix: prefix,
|
||||||
|
#delimiter: delimiter,
|
||||||
|
#pageSize: pageSize
|
||||||
|
}),
|
||||||
|
returnValue: Future<_i4.Page<_i3.BucketEntry>>.value(
|
||||||
|
_FakePage_2<_i3.BucketEntry>()))
|
||||||
|
as _i2.Future<_i4.Page<_i3.BucketEntry>>);
|
||||||
|
@override
|
||||||
|
String toString() => super.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A class which mocks [ObjectInfo].
|
||||||
|
///
|
||||||
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
|
class MockObjectInfo extends _i1.Mock implements _i3.ObjectInfo {
|
||||||
|
MockObjectInfo() {
|
||||||
|
_i1.throwOnMissingStub(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#name), returnValue: '') as String);
|
||||||
|
@override
|
||||||
|
int get length =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#length), returnValue: 0) as int);
|
||||||
|
@override
|
||||||
|
DateTime get updated => (super.noSuchMethod(Invocation.getter(#updated),
|
||||||
|
returnValue: _FakeDateTime_3()) as DateTime);
|
||||||
|
@override
|
||||||
|
String get etag =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#etag), returnValue: '') as String);
|
||||||
|
@override
|
||||||
|
List<int> get md5Hash =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#md5Hash), returnValue: <int>[])
|
||||||
|
as List<int>);
|
||||||
|
@override
|
||||||
|
int get crc32CChecksum =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#crc32CChecksum), returnValue: 0)
|
||||||
|
as int);
|
||||||
|
@override
|
||||||
|
Uri get downloadLink => (super.noSuchMethod(Invocation.getter(#downloadLink),
|
||||||
|
returnValue: _FakeUri_4()) as Uri);
|
||||||
|
@override
|
||||||
|
_i3.ObjectGeneration get generation =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#generation),
|
||||||
|
returnValue: _FakeObjectGeneration_5()) as _i3.ObjectGeneration);
|
||||||
|
@override
|
||||||
|
_i3.ObjectMetadata get metadata =>
|
||||||
|
(super.noSuchMethod(Invocation.getter(#metadata),
|
||||||
|
returnValue: _FakeObjectMetadata_6()) as _i3.ObjectMetadata);
|
||||||
|
@override
|
||||||
|
String toString() => super.toString();
|
||||||
|
}
|
@ -13,11 +13,11 @@ void expectSetMatch<T>(Iterable<T> actual, Iterable<T> expected) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// May return null if the credentials file doesn't exist.
|
// May return null if the credentials file doesn't exist.
|
||||||
Map<String, dynamic> getTestGcpCredentialsJson() {
|
Map<String, dynamic>? getTestGcpCredentialsJson() {
|
||||||
final File f = File('secret/test_gcp_credentials.json');
|
final File f = File('secret/test_gcp_credentials.json');
|
||||||
if (!f.existsSync()) {
|
if (!f.existsSync()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return jsonDecode(File('secret/test_gcp_credentials.json').readAsStringSync())
|
return jsonDecode(File('secret/test_gcp_credentials.json').readAsStringSync())
|
||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>?;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user