[fuchsia_modular_test] add ability to intercept agents

TEST=
- fx run-test fuchsia_modular_test_package_integration_tests

Change-Id: I70142800b72a6d98923f3a9527b4edd5bb4451ec
diff --git a/public/dart/fuchsia_modular/lib/src/lifecycle/internal/_lifecycle_impl.dart b/public/dart/fuchsia_modular/lib/src/lifecycle/internal/_lifecycle_impl.dart
index d425576..a791986 100644
--- a/public/dart/fuchsia_modular/lib/src/lifecycle/internal/_lifecycle_impl.dart
+++ b/public/dart/fuchsia_modular/lib/src/lifecycle/internal/_lifecycle_impl.dart
@@ -32,8 +32,8 @@
   final _terminateListeners = <Future<void> Function()>{};
 
   /// Initializes this [LifecycleImpl] instance
-  LifecycleImpl() {
-    _exposeService();
+  LifecycleImpl({StartupContext context}) {
+    _exposeService(context ?? StartupContext.fromStartupInfo());
   }
 
   /// Adds a terminate [listener] which will be called when the system starts to
@@ -62,8 +62,7 @@
   // Exposes this instance to the [StartupContext#outgoingServices].
   //
   // This class be must called before the first iteration of the event loop.
-  void _exposeService() {
-    StartupContext startupContext = StartupContext.fromStartupInfo();
+  void _exposeService(StartupContext startupContext) {
     startupContext.outgoing.addPublicService(
       (InterfaceRequest<fidl.Lifecycle> request) {
         _lifecycleBinding.bind(this, request);
diff --git a/public/dart/fuchsia_modular_test/BUILD.gn b/public/dart/fuchsia_modular_test/BUILD.gn
index 9e3370c..ecaaeb2 100644
--- a/public/dart/fuchsia_modular_test/BUILD.gn
+++ b/public/dart/fuchsia_modular_test/BUILD.gn
@@ -14,6 +14,7 @@
   sdk_category = "partner"
 
   sources = [
+    "src/agent_interceptor.dart",
     "src/test_harness_fixtures.dart",
     "src/test_harness_spec_builder.dart",
     "test.dart",
@@ -28,6 +29,7 @@
     "//third_party/dart-pkg/pub/test_api",
     "//topaz/public/dart/fidl",
     "//topaz/public/dart/fuchsia",
+    "//topaz/public/dart/fuchsia_logger",
     "//topaz/public/dart/fuchsia_modular",
     "//topaz/public/dart/fuchsia_services",
     "//topaz/public/dart/zircon",
@@ -35,6 +37,15 @@
   ]
 }
 
+
+fidl("test_fidl") {
+  name = "test.modular.dart"
+
+  sources = [
+    "test_support/fidl/testing.fidl",
+  ]
+}
+
 # Run tese tests using:
 #  fx run-test fuchsia_modular_test_package_integration_tests
 dart_fuchsia_test("fuchsia_modular_test_package_integration_tests") {
@@ -46,12 +57,14 @@
   ]
 
   sources = [
+    "agent_interceptor_test.dart",
     "launch_harness_test.dart",
     "test_harness_spec_builder_test.dart",
   ]
 
   deps = [
     ":fuchsia_modular_test",
+    ":test_fidl",
     "//third_party/dart-pkg/pub/test",
   ]
   environments = basic_envs
diff --git a/public/dart/fuchsia_modular_test/lib/src/agent_interceptor.dart b/public/dart/fuchsia_modular_test/lib/src/agent_interceptor.dart
new file mode 100644
index 0000000..5723faa
--- /dev/null
+++ b/public/dart/fuchsia_modular_test/lib/src/agent_interceptor.dart
@@ -0,0 +1,113 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:fuchsia_modular/agent.dart';
+import 'package:fuchsia_logger/logger.dart';
+import 'package:fidl/fidl.dart';
+import 'package:fidl_fuchsia_modular_testing/fidl_async.dart';
+import 'package:fidl_fuchsia_sys/fidl_async.dart' as fidl_sys;
+import 'package:fuchsia_modular/src/agent/internal/_agent_impl.dart'; // ignore: implementation_imports
+import 'package:fuchsia_services/src/internal/_startup_context_impl.dart'; // ignore: implementation_imports
+import 'package:fuchsia_modular/src/lifecycle/internal/_lifecycle_impl.dart'; // ignore: implementation_imports
+
+/// A function which is called when a new agent that is being launched.
+typedef OnNewAgent = void Function(Agent agent);
+
+/// A helper class for managing the intercepting of agents inside the
+/// [TestHarness].
+///
+/// When agents which are registered to be mocked are launchedm the [OnNewAgent]
+/// function will be executed allowing developers to expose services.
+///
+/// ```
+/// AgentInterceptor(testHarness.onNewComponent)
+///   .mockAgent(agentUrl, (agent) {
+///     agent.exposeService(myService);
+///   });
+///
+/// connectToAgentService(agentUrl, myServiceProxy,
+///     componentContextProxy, await getComponentContext(harness));
+/// ```
+class AgentInterceptor {
+  final _registeredAgents = <String, OnNewAgent>{};
+  final _mockedAgents = <String, _MockedAgent>{};
+
+  /// Creates an instance of this which will listen to the [onNewComponentStream].
+  AgentInterceptor(
+      Stream<TestHarness$OnNewComponent$Response> onNewComponentStream)
+      : assert(onNewComponentStream != null) {
+    onNewComponentStream.listen(_handleResponse);
+  }
+
+  /// Register an [agentUrl] to be mocked.
+  ///
+  /// If a component with the component url which matches [agentUrl] is
+  /// registered to be interecepted by the test harness [onNewAgent] will be
+  /// called when that component is first launched. The [onNewAgent] method will
+  /// be called with an injected [Agent] object. This method can be treated like
+  /// a normal main method in a non mocked agent.
+  void mockAgent(String agentUrl, OnNewAgent onNewAgent) {
+    ArgumentError.checkNotNull(agentUrl, 'agentUrl');
+    ArgumentError.checkNotNull(onNewAgent, 'onNewAgent');
+
+    if (agentUrl.isEmpty) {
+      throw ArgumentError('agentUrl must not be empty');
+    }
+
+    if (_registeredAgents.containsKey(agentUrl)) {
+      throw Exception(
+          'Attempting to add [$agentUrl] twice. Agent urls must be unique');
+    }
+    _registeredAgents[agentUrl] = onNewAgent;
+  }
+
+  /// This method is called by the listen method when this object is used as the
+  /// handler to the [TestHarnessProxy.onNewComponent] stream.
+  void _handleResponse(TestHarness$OnNewComponent$Response response) {
+    final startupInfo = response.startupInfo;
+    final componentUrl = startupInfo.launchInfo.url;
+    if (_registeredAgents.containsKey(componentUrl)) {
+      final mockedAgent = _MockedAgent(
+        startupInfo: startupInfo,
+        interceptedComponentRequest: response.interceptedComponent,
+      );
+      _mockedAgents[componentUrl] = mockedAgent;
+      _registeredAgents[componentUrl](mockedAgent.agent);
+    } else {
+      log.info(
+          'Skipping launched component [$componentUrl] because it was not registered');
+    }
+  }
+}
+
+/// A helper class which helps manage the lifecyle of a mocked agent
+class _MockedAgent {
+  /// The intercepted component. This object can be used to control the
+  /// launched component.
+  final InterceptedComponentProxy interceptedComponent =
+      InterceptedComponentProxy();
+
+  /// The instance of the [Agent] which is running in this environment
+  AgentImpl agent;
+
+  /// The startup context for this environment
+  StartupContextImpl context;
+
+  /// The lifecycle service for this environment
+  LifecycleImpl lifecycle;
+
+  _MockedAgent({
+    fidl_sys.StartupInfo startupInfo,
+    InterfaceHandle<InterceptedComponent> interceptedComponentRequest,
+  }) {
+    context = StartupContextImpl.from(startupInfo);
+    agent = AgentImpl(startupContext: context);
+    lifecycle = LifecycleImpl(context: context)
+      ..addTerminateListener(() async {
+        interceptedComponent.ctrl.close();
+      });
+
+    interceptedComponent.ctrl.bind(interceptedComponentRequest);
+  }
+}
diff --git a/public/dart/fuchsia_modular_test/lib/src/test_harness_fixtures.dart b/public/dart/fuchsia_modular_test/lib/src/test_harness_fixtures.dart
index 33e440d..7f2c950 100644
--- a/public/dart/fuchsia_modular_test/lib/src/test_harness_fixtures.dart
+++ b/public/dart/fuchsia_modular_test/lib/src/test_harness_fixtures.dart
@@ -6,6 +6,7 @@
 import 'dart:math';
 
 import 'package:fidl_fuchsia_modular_testing/fidl_async.dart' as fidl_testing;
+import 'package:fidl_fuchsia_modular/fidl_async.dart' as fidl_modular;
 import 'package:fidl_fuchsia_sys/fidl_async.dart' as fidl_sys;
 import 'package:fuchsia_services/services.dart';
 
@@ -29,15 +30,17 @@
       directoryRequest: incoming.request().passChannel());
   await launcher.createComponent(
       launchInfo, componentControllerProxy.ctrl.request());
-
   launcher.ctrl.close();
 
   // hold a reference to the componentControllerProxy so it lives as long as the
   // harness and does not kill the service if it is garbage collected.
   // ignore: unawaited_futures
-  harness.ctrl.whenClosed.then((_) => componentControllerProxy.ctrl.close());
+  harness.ctrl.whenClosed.then((_) {
+    componentControllerProxy.ctrl.close();
+  });
 
   incoming.connectToService(harness);
+  await incoming.close();
 
   return harness;
 }
@@ -48,3 +51,13 @@
   final name = List.generate(10, (_) => rand.nextInt(9).toString()).join('');
   return 'fuchsia-pkg://example.com/$name#meta/$name.cmx';
 }
+
+/// Returns the connection to [fidl_modular.ComponentContextProxy] which is
+/// running inside the [harness]'s hermetic environment
+Future<fidl_modular.ComponentContextProxy> getComponentContext(
+    fidl_testing.TestHarnessProxy harness) async {
+  final proxy = fidl_modular.ComponentContextProxy();
+  await harness.connectToModularService(
+      fidl_testing.ModularService.withComponentContext(proxy.ctrl.request()));
+  return proxy;
+}
diff --git a/public/dart/fuchsia_modular_test/lib/test.dart b/public/dart/fuchsia_modular_test/lib/test.dart
index 059ce58..2268073 100644
--- a/public/dart/fuchsia_modular_test/lib/test.dart
+++ b/public/dart/fuchsia_modular_test/lib/test.dart
@@ -4,5 +4,6 @@
 
 /// A collection of utilities to help with writing tests
 /// against the fuchsia_modular package.
+export 'src/agent_interceptor.dart';
 export 'src/test_harness_fixtures.dart';
 export 'src/test_harness_spec_builder.dart';
diff --git a/public/dart/fuchsia_modular_test/test/agent_interceptor_test.dart b/public/dart/fuchsia_modular_test/test/agent_interceptor_test.dart
new file mode 100644
index 0000000..4dd8a0f
--- /dev/null
+++ b/public/dart/fuchsia_modular_test/test/agent_interceptor_test.dart
@@ -0,0 +1,117 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+// ignore_for_file: implementation_imports
+import 'package:fuchsia_modular_test/src/test_harness_spec_builder.dart';
+import 'package:fuchsia_modular_test/test.dart';
+import 'package:test/test.dart';
+import 'package:fidl_fuchsia_modular_testing/fidl_async.dart';
+import 'package:fuchsia_logger/logger.dart';
+import 'package:fuchsia_modular_test/src/agent_interceptor.dart';
+import 'package:fuchsia_modular_test/src/test_harness_fixtures.dart';
+import 'package:fuchsia_modular/service_connection.dart';
+import 'package:fidl_test_modular_dart/fidl_async.dart';
+
+void main() {
+  setupLogger();
+
+  group('mock registration', () {
+    AgentInterceptor agentInterceptor;
+
+    setUp(() {
+      agentInterceptor =
+          AgentInterceptor(Stream<TestHarness$OnNewComponent$Response>.empty());
+    });
+
+    test('mockAgent throws for null agentUrl', () {
+      expect(
+          () => agentInterceptor.mockAgent(null, (_) {}), throwsArgumentError);
+    });
+
+    test('mockAgent throws for empty agentUrl', () {
+      expect(() => agentInterceptor.mockAgent('', (_) {}), throwsArgumentError);
+    });
+
+    test('mockAgent throws for missing callback', () {
+      expect(() => agentInterceptor.mockAgent(generateComponentUrl(), null),
+          throwsArgumentError);
+    });
+
+    test('mockAgent throws for registering agent twice', () {
+      final agentUrl = generateComponentUrl();
+      void callback(_) {}
+
+      agentInterceptor.mockAgent(agentUrl, callback);
+
+      expect(() => agentInterceptor.mockAgent(agentUrl, callback),
+          throwsException);
+    });
+  });
+
+  group('agent intercepting', () {
+    TestHarnessProxy harness;
+    TestHarnessSpec spec;
+    String agentUrl;
+
+    setUp(() async {
+      agentUrl = generateComponentUrl();
+      harness = await launchTestHarness();
+      spec =
+          (TestHarnessSpecBuilder()..addComponentToIntercept(agentUrl)).build();
+    });
+
+    tearDown(() {
+      harness.ctrl.close();
+    });
+
+    test('onNewAgent called for mocked agent', () async {
+      final didCallCompleter = Completer<bool>();
+      AgentInterceptor(harness.onNewComponent).mockAgent(agentUrl, (agent) {
+        expect(agent, isNotNull);
+        didCallCompleter.complete(true);
+      });
+
+      await harness.run(spec);
+
+      final componentContext = await getComponentContext(harness);
+      final proxy = ServerProxy();
+      connectToAgentService(agentUrl, proxy,
+          componentContextProxy: componentContext);
+      componentContext.ctrl.close();
+      proxy.ctrl.close();
+      expect(await didCallCompleter.future, isTrue);
+    });
+
+    test('onNewAgent can expose a service', () async {
+      final spec =
+          (TestHarnessSpecBuilder()..addComponentToIntercept(agentUrl)).build();
+
+      final server = _ServerImpl();
+      AgentInterceptor(harness.onNewComponent).mockAgent(agentUrl, (agent) {
+        agent.exposeService(server);
+      });
+
+      await harness.run(spec);
+
+      final fooProxy = ServerProxy();
+      final componentContext = await getComponentContext(harness);
+      connectToAgentService(agentUrl, fooProxy,
+          componentContextProxy: componentContext);
+
+      expect(await fooProxy.echo('some value'), 'some value');
+
+      fooProxy.ctrl.close();
+      componentContext.ctrl.close();
+    });
+  });
+}
+
+class _ServerImpl extends Server {
+  @override
+  Future<String> echo(String value) async {
+    return value;
+  }
+}
diff --git a/public/dart/fuchsia_modular_test/test/launch_harness_test.dart b/public/dart/fuchsia_modular_test/test/launch_harness_test.dart
index 36078b5..267b012 100644
--- a/public/dart/fuchsia_modular_test/test/launch_harness_test.dart
+++ b/public/dart/fuchsia_modular_test/test/launch_harness_test.dart
@@ -40,6 +40,7 @@
 
       final storyPuppetMaster = fidl_modular.StoryPuppetMasterProxy();
       await puppetMaster.controlStory('foo', storyPuppetMaster.ctrl.request());
+      puppetMaster.ctrl.close();
 
       final addMod = fidl_modular.AddMod(
         intent: fidl_modular.Intent(action: '', handler: url),
@@ -51,6 +52,7 @@
       await storyPuppetMaster
           .enqueue([fidl_modular.StoryCommand.withAddMod(addMod)]);
       await storyPuppetMaster.execute();
+      storyPuppetMaster.ctrl.close();
     });
   });
 }
diff --git a/public/dart/fuchsia_modular_test/test_support/fidl/testing.fidl b/public/dart/fuchsia_modular_test/test_support/fidl/testing.fidl
new file mode 100644
index 0000000..6b5fb4f
--- /dev/null
+++ b/public/dart/fuchsia_modular_test/test_support/fidl/testing.fidl
@@ -0,0 +1,10 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+library test.modular.dart;
+
+[Discoverable]
+protocol Server {
+    echo(string? value) -> (string? response);
+};
diff --git a/public/dart/fuchsia_services/lib/src/incoming.dart b/public/dart/fuchsia_services/lib/src/incoming.dart
index 8178101..4e15fd8 100644
--- a/public/dart/fuchsia_services/lib/src/incoming.dart
+++ b/public/dart/fuchsia_services/lib/src/incoming.dart
@@ -47,7 +47,9 @@
 
   /// Terminates connection and return Zircon status.
   Future<int> close() async {
-    return _dirProxy.close();
+    final status = await _dirProxy.close();
+    _dirProxy.ctrl.close();
+    return status;
   }
 
   /// Connects to the incoming service specified by [serviceProxy].