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,18 +37,22 @@ 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.
- Create **small PRs** that are narrowly focused on **addressing a single
concern**.
concern**.
- For speculative changes, consider opening an issue and discussing it first.
- Provide a good **PR description** as a record of **what** change is being made
and **why** it was made. Link to a github issue if it exists.
- Unless your PR is trivial, you should expect there will be review comments
that you'll need to address before merging. We expect you to be reasonably
responsive to those comments, otherwise the PR will be closed after 2-3 weeks of
@ -60,6 +64,6 @@ can't really merge your change).
- **All tests need to be passing** before your change can be merged. We
recommend you **run tests locally** before creating your PR to catch breakages
early on.
- Exceptions to the rules can be made if there's a compelling reason for doing
so.

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}