Add a simple end-to-end gRPC-web test (#354)

We did not have any before which allowed for regressions like #306 to
slip through. Unfortunately we can't test gRPC-web implementation
in pure Dart because we don't have a server side implementation of
the protocol. Instead we add a dependency on the third party
gRPC-web proxy (grpcwebproxy by Improbable Engineering - the choice made
purely based on the simplicity of installation) which forwards all
request gRPC server (written in Dart).
This commit is contained in:
Vyacheslav Egorov 2020-10-25 12:53:53 +01:00 committed by GitHub
parent e2f3d74087
commit 2957ec003f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 520 additions and 5 deletions

View File

@ -8,6 +8,8 @@ addons:
# The Chrome addon does not work on windows
before_install:
- if [ $TRAVIS_OS_NAME = windows ]; then choco install googlechrome ; fi
- ./tool/install-grpcwebproxy.sh
- export PATH="$PATH:/tmp/grpcwebproxy"
# Run against both the dev and stable channel.
dart:

View File

@ -37,6 +37,10 @@ pub get
pub run test
```
gRPC-web tests require [`grpcwebproxy`](
https://github.com/improbable-eng/grpc-web/tree/master/go/grpcwebproxy) by
Improbable Engineering to be available in the PATH. Pre-built binaries are [available](https://github.com/improbable-eng/grpc-web/releases).
## Guidelines for Pull Requests
How to get your contributions merged smoothly and quickly.

View File

@ -23,3 +23,4 @@ dev_dependencies:
build_web_compilers: ^2.1.1
mockito: ^4.1.0
test: ^1.6.4
stream_channel: ^2.0.0

92
test/grpc_web_server.dart Normal file
View File

@ -0,0 +1,92 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:grpc/grpc.dart';
import 'package:stream_channel/stream_channel.dart';
import 'src/generated/echo.pbgrpc.dart';
/// Controls verbosity of output during the test. Flip to [true] for easier
/// debugging.
const verbose = false;
class EchoService extends EchoServiceBase {
@override
Future<EchoResponse> echo(ServiceCall call, EchoRequest request) async {
return EchoResponse()..message = request.message;
}
@override
Stream<ServerStreamingEchoResponse> serverStreamingEcho(
ServiceCall call, ServerStreamingEchoRequest request) async* {
for (var i = 0; i < request.messageCount; i++) {
yield ServerStreamingEchoResponse()..message = request.message;
if (i < request.messageCount - 1) {
await Future.delayed(Duration(milliseconds: request.messageInterval));
}
}
}
}
hybridMain(StreamChannel channel) async {
// Spawn a gRPC server.
final server = Server([EchoService()]);
await server.serve(port: 0);
_info('grpc server listening on ${server.port}');
// Spawn a proxy that would translate gRPC-web protocol into gRPC protocol
// for us. We use grpcwebproxy by Improbable Engineering. See CONTRIBUTING.md
// for setup.
Process proxy;
try {
proxy =
await Process.start('grpcwebproxy${Platform.isWindows ? '.exe' : ''}', [
'--backend_addr',
'localhost:${server.port}',
'--run_tls_server=false',
'--server_http_debug_port',
'0',
'--allow_all_origins',
]);
} catch (e) {
print('''
Failed to start grpcwebproxy: $e.
Make sure that grpcwebproxy is available in the PATH see CONTRIBUTING.md
if you are running tests locally.
''');
channel.sink.add(0);
return;
}
// Parse output of the proxy process looking for a port it selected.
final portRe = RegExp(r'listening for http on: .*:(\d+)');
proxy.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
_info('grpcwebproxy|stderr] $line');
});
proxy.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
_info('grpcwebproxy|stdout] $line');
final m = portRe.firstMatch(line);
if (m != null) {
final port = int.parse(m[1]);
channel.sink.add(port);
}
});
proxy.exitCode.then((value) => _info('proxy quit with ${value}'));
}
void _info(String line) {
if (verbose) {
print(line);
}
}

64
test/grpc_web_test.dart Normal file
View File

@ -0,0 +1,64 @@
@TestOn('browser')
import 'dart:math' as math;
import 'package:test/test.dart';
import 'package:grpc/grpc_web.dart';
import 'src/generated/echo.pbgrpc.dart';
/// Starts gRPC server and a gRPC-web proxy (see grpc_web_server.dart for
/// implementation.
///
/// Returns uri which can be used to talk to using gRPC-web channel.
///
/// Note: server will be shut down when the test which spawned it finishes
/// running.
Future<Uri> startServer() async {
// Spawn the server code on the server side, it will send us back port
// number we should be talking to.
final serverChannel = spawnHybridUri('grpc_web_server.dart');
final port = await serverChannel.stream.first;
// Note: we would like to test https as well, but we can't easily do it
// because browsers like chrome don't trust self-signed certificates by
// default.
return Uri.parse('http://localhost:$port');
}
void main() {
// Test verifies that gRPC-web echo example works by talking to a gRPC
// server (written in Dart) via gRPC-web protocol through a third party
// gRPC-web proxy.
test('gRPC-web echo test', () async {
final serverUri = await startServer();
final channel = GrpcWebClientChannel.xhr(serverUri);
final service = EchoServiceClient(channel);
const testMessage = 'hello from gRPC-web';
// First test a simple echo request.
final response = await service.echo(EchoRequest()..message = testMessage);
expect(response.message, equals(testMessage));
// Now test that streaming requests also works by asking echo server
// to send us a number of messages every 100 ms. Check that we receive
// them fast enough (if streaming is broken we will receive all of them
// in one go).
final sw = Stopwatch()..start();
final timings = await service
.serverStreamingEcho(ServerStreamingEchoRequest()
..message = testMessage
..messageCount = 20
..messageInterval = 100)
.map((response) {
expect(response.message, equals(testMessage));
final timing = sw.elapsedMilliseconds;
sw.reset();
return timing;
}).toList();
final maxDelay = timings.reduce(math.max);
expect(maxDelay, lessThan(500));
});
}

View File

@ -0,0 +1,155 @@
///
// Generated code. Do not modify.
// source: echo.proto
//
// @dart = 2.3
// ignore_for_file: camel_case_types,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class EchoRequest extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo('EchoRequest', package: const $pb.PackageName('grpc.gateway.testing'), createEmptyInstance: create)
..aOS(1, 'message')
..hasRequiredFields = false
;
EchoRequest._() : super();
factory EchoRequest() => create();
factory EchoRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory EchoRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
EchoRequest clone() => EchoRequest()..mergeFromMessage(this);
EchoRequest copyWith(void Function(EchoRequest) updates) => super.copyWith((message) => updates(message as EchoRequest));
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static EchoRequest create() => EchoRequest._();
EchoRequest createEmptyInstance() => create();
static $pb.PbList<EchoRequest> createRepeated() => $pb.PbList<EchoRequest>();
@$core.pragma('dart2js:noInline')
static EchoRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<EchoRequest>(create);
static EchoRequest _defaultInstance;
@$pb.TagNumber(1)
$core.String get message => $_getSZ(0);
@$pb.TagNumber(1)
set message($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasMessage() => $_has(0);
@$pb.TagNumber(1)
void clearMessage() => clearField(1);
}
class EchoResponse extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo('EchoResponse', package: const $pb.PackageName('grpc.gateway.testing'), createEmptyInstance: create)
..aOS(1, 'message')
..hasRequiredFields = false
;
EchoResponse._() : super();
factory EchoResponse() => create();
factory EchoResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory EchoResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
EchoResponse clone() => EchoResponse()..mergeFromMessage(this);
EchoResponse copyWith(void Function(EchoResponse) updates) => super.copyWith((message) => updates(message as EchoResponse));
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static EchoResponse create() => EchoResponse._();
EchoResponse createEmptyInstance() => create();
static $pb.PbList<EchoResponse> createRepeated() => $pb.PbList<EchoResponse>();
@$core.pragma('dart2js:noInline')
static EchoResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<EchoResponse>(create);
static EchoResponse _defaultInstance;
@$pb.TagNumber(1)
$core.String get message => $_getSZ(0);
@$pb.TagNumber(1)
set message($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasMessage() => $_has(0);
@$pb.TagNumber(1)
void clearMessage() => clearField(1);
}
class ServerStreamingEchoRequest extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo('ServerStreamingEchoRequest', package: const $pb.PackageName('grpc.gateway.testing'), createEmptyInstance: create)
..aOS(1, 'message')
..a<$core.int>(2, 'messageCount', $pb.PbFieldType.O3)
..a<$core.int>(3, 'messageInterval', $pb.PbFieldType.O3)
..hasRequiredFields = false
;
ServerStreamingEchoRequest._() : super();
factory ServerStreamingEchoRequest() => create();
factory ServerStreamingEchoRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ServerStreamingEchoRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
ServerStreamingEchoRequest clone() => ServerStreamingEchoRequest()..mergeFromMessage(this);
ServerStreamingEchoRequest copyWith(void Function(ServerStreamingEchoRequest) updates) => super.copyWith((message) => updates(message as ServerStreamingEchoRequest));
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ServerStreamingEchoRequest create() => ServerStreamingEchoRequest._();
ServerStreamingEchoRequest createEmptyInstance() => create();
static $pb.PbList<ServerStreamingEchoRequest> createRepeated() => $pb.PbList<ServerStreamingEchoRequest>();
@$core.pragma('dart2js:noInline')
static ServerStreamingEchoRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ServerStreamingEchoRequest>(create);
static ServerStreamingEchoRequest _defaultInstance;
@$pb.TagNumber(1)
$core.String get message => $_getSZ(0);
@$pb.TagNumber(1)
set message($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasMessage() => $_has(0);
@$pb.TagNumber(1)
void clearMessage() => clearField(1);
@$pb.TagNumber(2)
$core.int get messageCount => $_getIZ(1);
@$pb.TagNumber(2)
set messageCount($core.int v) { $_setSignedInt32(1, v); }
@$pb.TagNumber(2)
$core.bool hasMessageCount() => $_has(1);
@$pb.TagNumber(2)
void clearMessageCount() => clearField(2);
@$pb.TagNumber(3)
$core.int get messageInterval => $_getIZ(2);
@$pb.TagNumber(3)
set messageInterval($core.int v) { $_setSignedInt32(2, v); }
@$pb.TagNumber(3)
$core.bool hasMessageInterval() => $_has(2);
@$pb.TagNumber(3)
void clearMessageInterval() => clearField(3);
}
class ServerStreamingEchoResponse extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo('ServerStreamingEchoResponse', package: const $pb.PackageName('grpc.gateway.testing'), createEmptyInstance: create)
..aOS(1, 'message')
..hasRequiredFields = false
;
ServerStreamingEchoResponse._() : super();
factory ServerStreamingEchoResponse() => create();
factory ServerStreamingEchoResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory ServerStreamingEchoResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
ServerStreamingEchoResponse clone() => ServerStreamingEchoResponse()..mergeFromMessage(this);
ServerStreamingEchoResponse copyWith(void Function(ServerStreamingEchoResponse) updates) => super.copyWith((message) => updates(message as ServerStreamingEchoResponse));
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ServerStreamingEchoResponse create() => ServerStreamingEchoResponse._();
ServerStreamingEchoResponse createEmptyInstance() => create();
static $pb.PbList<ServerStreamingEchoResponse> createRepeated() => $pb.PbList<ServerStreamingEchoResponse>();
@$core.pragma('dart2js:noInline')
static ServerStreamingEchoResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ServerStreamingEchoResponse>(create);
static ServerStreamingEchoResponse _defaultInstance;
@$pb.TagNumber(1)
$core.String get message => $_getSZ(0);
@$pb.TagNumber(1)
set message($core.String v) { $_setString(0, v); }
@$pb.TagNumber(1)
$core.bool hasMessage() => $_has(0);
@$pb.TagNumber(1)
void clearMessage() => clearField(1);
}

View File

@ -0,0 +1,7 @@
///
// Generated code. Do not modify.
// source: echo.proto
//
// @dart = 2.3
// ignore_for_file: camel_case_types,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type

View File

@ -0,0 +1,85 @@
///
// Generated code. Do not modify.
// source: echo.proto
//
// @dart = 2.3
// ignore_for_file: camel_case_types,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type
import 'dart:async' as $async;
import 'dart:core' as $core;
import 'package:grpc/service_api.dart' as $grpc;
import 'echo.pb.dart' as $0;
export 'echo.pb.dart';
class EchoServiceClient extends $grpc.Client {
static final _$echo = $grpc.ClientMethod<$0.EchoRequest, $0.EchoResponse>(
'/grpc.gateway.testing.EchoService/Echo',
($0.EchoRequest value) => value.writeToBuffer(),
($core.List<$core.int> value) => $0.EchoResponse.fromBuffer(value));
static final _$serverStreamingEcho = $grpc.ClientMethod<
$0.ServerStreamingEchoRequest, $0.ServerStreamingEchoResponse>(
'/grpc.gateway.testing.EchoService/ServerStreamingEcho',
($0.ServerStreamingEchoRequest value) => value.writeToBuffer(),
($core.List<$core.int> value) =>
$0.ServerStreamingEchoResponse.fromBuffer(value));
EchoServiceClient($grpc.ClientChannel channel, {$grpc.CallOptions options})
: super(channel, options: options);
$grpc.ResponseFuture<$0.EchoResponse> echo($0.EchoRequest request,
{$grpc.CallOptions options}) {
final call = $createCall(_$echo, $async.Stream.fromIterable([request]),
options: options);
return $grpc.ResponseFuture(call);
}
$grpc.ResponseStream<$0.ServerStreamingEchoResponse> serverStreamingEcho(
$0.ServerStreamingEchoRequest request,
{$grpc.CallOptions options}) {
final call = $createCall(
_$serverStreamingEcho, $async.Stream.fromIterable([request]),
options: options);
return $grpc.ResponseStream(call);
}
}
abstract class EchoServiceBase extends $grpc.Service {
$core.String get $name => 'grpc.gateway.testing.EchoService';
EchoServiceBase() {
$addMethod($grpc.ServiceMethod<$0.EchoRequest, $0.EchoResponse>(
'Echo',
echo_Pre,
false,
false,
($core.List<$core.int> value) => $0.EchoRequest.fromBuffer(value),
($0.EchoResponse value) => value.writeToBuffer()));
$addMethod($grpc.ServiceMethod<$0.ServerStreamingEchoRequest,
$0.ServerStreamingEchoResponse>(
'ServerStreamingEcho',
serverStreamingEcho_Pre,
false,
true,
($core.List<$core.int> value) =>
$0.ServerStreamingEchoRequest.fromBuffer(value),
($0.ServerStreamingEchoResponse value) => value.writeToBuffer()));
}
$async.Future<$0.EchoResponse> echo_Pre(
$grpc.ServiceCall call, $async.Future<$0.EchoRequest> request) async {
return echo(call, await request);
}
$async.Stream<$0.ServerStreamingEchoResponse> serverStreamingEcho_Pre(
$grpc.ServiceCall call,
$async.Future<$0.ServerStreamingEchoRequest> request) async* {
yield* serverStreamingEcho(call, await request);
}
$async.Future<$0.EchoResponse> echo(
$grpc.ServiceCall call, $0.EchoRequest request);
$async.Stream<$0.ServerStreamingEchoResponse> serverStreamingEcho(
$grpc.ServiceCall call, $0.ServerStreamingEchoRequest request);
}

View File

@ -0,0 +1,37 @@
///
// Generated code. Do not modify.
// source: echo.proto
//
// @dart = 2.3
// ignore_for_file: camel_case_types,non_constant_identifier_names,library_prefixes,unused_import,unused_shown_name,return_of_invalid_type
const EchoRequest$json = const {
'1': 'EchoRequest',
'2': const [
const {'1': 'message', '3': 1, '4': 1, '5': 9, '10': 'message'},
],
};
const EchoResponse$json = const {
'1': 'EchoResponse',
'2': const [
const {'1': 'message', '3': 1, '4': 1, '5': 9, '10': 'message'},
],
};
const ServerStreamingEchoRequest$json = const {
'1': 'ServerStreamingEchoRequest',
'2': const [
const {'1': 'message', '3': 1, '4': 1, '5': 9, '10': 'message'},
const {'1': 'message_count', '3': 2, '4': 1, '5': 5, '10': 'messageCount'},
const {'1': 'message_interval', '3': 3, '4': 1, '5': 5, '10': 'messageInterval'},
],
};
const ServerStreamingEchoResponse$json = const {
'1': 'ServerStreamingEchoResponse',
'2': const [
const {'1': 'message', '3': 1, '4': 1, '5': 9, '10': 'message'},
],
};

View File

@ -0,0 +1,41 @@
// Copyright 2018 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package grpc.gateway.testing;
message EchoRequest {
string message = 1;
}
message EchoResponse {
string message = 1;
}
message ServerStreamingEchoRequest {
string message = 1;
int32 message_count = 2;
int32 message_interval = 3;
}
message ServerStreamingEchoResponse {
string message = 1;
}
service EchoService {
rpc Echo(EchoRequest) returns (EchoResponse);
rpc ServerStreamingEcho(ServerStreamingEchoRequest)
returns (stream ServerStreamingEchoResponse);
}

27
tool/install-grpcwebproxy.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/sh
VERSION=v0.13.0
SUFFIX=
case $TRAVIS_OS_NAME in
linux)
VARIANT=linux-x86_64
;;
osx)
VARIANT=osx-x86_64
;;
windows)
VARIANT=win64.exe
SUFFIX=.exe
;;
esac
BINARY=grpcwebproxy-${VERSION}-${VARIANT}
wget https://github.com/improbable-eng/grpc-web/releases/download/${VERSION}/${BINARY}.zip -O /tmp/grpcwebproxy.zip
rm -rf /tmp/grpcwebproxy
mkdir /tmp/grpcwebproxy
cd /tmp/grpcwebproxy
unzip /tmp/grpcwebproxy.zip
mv dist/${BINARY} ./grpcwebproxy${SUFFIX}