Chromium Code Reviews| Index: pkg/stack_trace/test/chain_test.dart |
| diff --git a/pkg/stack_trace/test/chain_test.dart b/pkg/stack_trace/test/chain_test.dart |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..9c4947da28468e63684da2ac0bb3281ce2b4b0e6 |
| --- /dev/null |
| +++ b/pkg/stack_trace/test/chain_test.dart |
| @@ -0,0 +1,614 @@ |
| +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file |
| +// for details. All rights reserved. Use of this source code is governed by a |
| +// BSD-style license that can be found in the LICENSE file. |
| + |
| +library chain_test; |
| + |
| +import 'dart:async'; |
| + |
| +import 'package:stack_trace/stack_trace.dart'; |
| +import 'package:unittest/unittest.dart'; |
| + |
| +import 'utils.dart'; |
| + |
| +void main() { |
| + group('capture() with onError catches exceptions', () { |
| + test('thrown in a microtask', () { |
| + return capture(() => inMicrotask(() => throw 'error')).then((chain) { |
| + // Since there was only one asynchronous operation, there should be only |
| + // two traces in the chain. |
| + expect(chain.traces, hasLength(2)); |
| + |
| + // The first frame of the first trace should be the line on which the |
| + // actual error was thrown. |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + |
| + // The second trace should describe the stack when the error callback |
| + // was scheduled. |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('thrown in a one-shot timer', () { |
| + return capture(() => inOneShotTimer(() => throw 'error')).then((chain) { |
| + expect(chain.traces, hasLength(2)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inOneShotTimer')))); |
|
Bob Nystrom
2013/11/20 20:19:54
How about a helper function for these expectations
|
| + }); |
| + }); |
| + |
| + test('thrown in a periodic timer', () { |
| + return capture(() => inPeriodicTimer(() => throw 'error')).then((chain) { |
| + expect(chain.traces, hasLength(2)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + }); |
| + }); |
| + |
| + test('thrown in a nested series of asynchronous operations', () { |
| + return capture(() { |
| + inPeriodicTimer(() { |
| + inOneShotTimer(() => inMicrotask(() => throw 'error')); |
| + }); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(4)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inOneShotTimer')))); |
| + expect(chain.traces[3].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + }); |
| + }); |
| + |
| + test('thrown in a long future chain', () { |
| + return capture(() => inFutureChain(() => throw 'error')).then((chain) { |
| + // Despite many asynchronous operations, there's only one level of |
| + // nested calls, so there should be only two traces in the chain. This |
| + // is important; programmers expect stack trace memory consumption to be |
| + // O(depth of program), not O(length of progam). |
| + expect(chain.traces, hasLength(2)); |
| + |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inFutureChain')))); |
| + }); |
| + }); |
| + |
| + test('multiple times', () { |
| + var completer = new Completer(); |
| + var first = true; |
| + |
| + Chain.capture(() { |
| + inMicrotask(() => throw 'first error'); |
| + inPeriodicTimer(() => throw 'second error'); |
| + }, onError: (error, chain) { |
| + if (first) { |
| + expect(error, equals('first error')); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + first = false; |
| + } else { |
| + expect(error, equals('second error')); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + completer.complete(); |
| + } |
| + }); |
| + |
| + return completer.future; |
| + }); |
| + }); |
| + |
| + test('capture() without onError passes exceptions to parent zone', () { |
| + var completer = new Completer(); |
| + |
| + runZoned(() { |
| + Chain.capture(() => inMicrotask(() => throw 'error')); |
| + }, onError: (error, chain) { |
| + expect(error, equals('error')); |
| + expect(chain, new isInstanceOf<Chain>()); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + completer.complete(); |
| + }); |
| + |
| + return completer.future; |
| + }); |
| + |
| + group('current() within capture()', () { |
| + test('called in a microtask', () { |
| + var completer = new Completer(); |
| + Chain.capture(() { |
| + inMicrotask(() => completer.complete(new Chain.current())); |
| + }); |
| + |
| + return completer.future.then((chain) { |
| + expect(chain.traces, hasLength(2)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('called in a one-shot timer', () { |
| + var completer = new Completer(); |
| + Chain.capture(() { |
| + inOneShotTimer(() => completer.complete(new Chain.current())); |
| + }); |
| + |
| + return completer.future.then((chain) { |
| + expect(chain.traces, hasLength(2)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inOneShotTimer')))); |
| + }); |
| + }); |
| + |
| + test('called in a periodic timer', () { |
| + var completer = new Completer(); |
| + Chain.capture(() { |
| + inPeriodicTimer(() => completer.complete(new Chain.current())); |
| + }); |
| + |
| + return completer.future.then((chain) { |
| + expect(chain.traces, hasLength(2)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + }); |
| + }); |
| + |
| + test('called in a nested series of asynchronous operations', () { |
| + var completer = new Completer(); |
| + Chain.capture(() { |
| + inPeriodicTimer(() { |
| + inOneShotTimer(() { |
| + inMicrotask(() => completer.complete(new Chain.current())); |
| + }); |
| + }); |
| + }); |
| + |
| + return completer.future.then((chain) { |
| + expect(chain.traces, hasLength(4)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inOneShotTimer')))); |
| + expect(chain.traces[3].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + }); |
| + }); |
| + |
| + test('called in a long future chain', () { |
| + var completer = new Completer(); |
| + Chain.capture(() { |
| + inFutureChain(() => completer.complete(new Chain.current())); |
| + }); |
| + |
| + return completer.future.then((chain) { |
| + expect(chain.traces, hasLength(2)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('inFutureChain')))); |
| + }); |
| + }); |
| + }); |
| + |
| + test('current() outside of capture() returns a chain wrapping the current ' |
| + 'trace', () { |
| + var completer = new Completer(); |
| + inMicrotask(() => completer.complete(new Chain.current())); |
| + |
| + return completer.future.then((chain) { |
| + // Since the chain wasn't loaded within [Chain.capture], the full stack |
| + // chain isn't available and it just returns the current stack when |
| + // called. |
| + expect(chain.traces, hasLength(1)); |
| + expect(chain.traces.first.frames.first, frameMember(startsWith('main'))); |
| + }); |
| + }); |
| + |
| + group('forTrace() within capture()', () { |
| + test('called for a stack trace from a microtask', () { |
| + return Chain.capture(() { |
| + return chainForTrace(inMicrotask, () => throw 'error'); |
| + }).then((chain) { |
| + // Because [chainForTrace] has to set up a future chain to capture the |
| + // stack trace while still showing it to the zone specification, it adds |
| + // an additional level of async nesting and so an additional trace. |
| + expect(chain.traces, hasLength(3)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('chainForTrace')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('called for a stack trace from a one-shot timer', () { |
| + return Chain.capture(() { |
| + return chainForTrace(inOneShotTimer, () => throw 'error'); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('chainForTrace')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inOneShotTimer')))); |
| + }); |
| + }); |
| + |
| + test('called for a stack trace from a periodic timer', () { |
| + return Chain.capture(() { |
| + return chainForTrace(inPeriodicTimer, () => throw 'error'); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('chainForTrace')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + }); |
| + }); |
| + |
| + test('called for a stack trace from a nested series of asynchronous ' |
| + 'operations', () { |
| + return Chain.capture(() { |
| + return chainForTrace((callback) { |
| + inPeriodicTimer(() => inOneShotTimer(() => inMicrotask(callback))); |
| + }, () => throw 'error'); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(5)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('chainForTrace')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + expect(chain.traces[3].frames, |
| + contains(frameMember(startsWith('inOneShotTimer')))); |
| + expect(chain.traces[4].frames, |
| + contains(frameMember(startsWith('inPeriodicTimer')))); |
| + }); |
| + }); |
| + |
| + test('called for a stack trace from a long future chain', () { |
| + return Chain.capture(() { |
| + return chainForTrace(inFutureChain, () => throw 'error'); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); |
| + expect(chain.traces[1].frames, |
| + contains(frameMember(startsWith('chainForTrace')))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inFutureChain')))); |
| + }); |
| + }); |
| + |
| + test('called for an unregistered stack trace returns a chain wrapping that ' |
| + 'trace', () { |
| + var trace; |
| + var chain = Chain.capture(() { |
| + try { |
| + throw 'error'; |
| + } catch (_, stackTrace) { |
| + trace = stackTrace; |
| + return new Chain.forTrace(stackTrace); |
| + } |
| + }); |
| + |
| + expect(chain.traces, hasLength(1)); |
| + expect(chain.traces.first.toString(), |
| + equals(new Trace.from(trace).toString())); |
| + }); |
| + }); |
| + |
| + test('forTrace() outside of capture() returns a chain wrapping the given ' |
| + 'trace', () { |
| + var trace; |
| + var chain = Chain.capture(() { |
| + try { |
| + throw 'error'; |
| + } catch (_, stackTrace) { |
| + trace = stackTrace; |
| + return new Chain.forTrace(stackTrace); |
| + } |
| + }); |
| + |
| + expect(chain.traces, hasLength(1)); |
| + expect(chain.traces.first.toString(), |
| + equals(new Trace.from(trace).toString())); |
| + }); |
| + |
| + test('Chain.parse() parses a real Chain', () { |
| + return capture(() => inMicrotask(() => throw 'error')).then((chain) { |
| + expect(new Chain.parse(chain.toString()).toString(), |
| + equals(chain.toString())); |
| + }); |
| + }); |
| + |
| + group('Chain.terse', () { |
| + test('makes each trace terse', () { |
| + var chain = new Chain([ |
| + new Trace.parse( |
| + 'dart:core 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz\n' |
| + 'user/code.dart 10:11 Bang.qux\n' |
| + 'dart:core 10:11 Zip.zap\n' |
| + 'dart:core 10:11 Zop.zoop'), |
| + new Trace.parse( |
| + 'user/code.dart 10:11 Bang.qux\n' |
| + 'dart:core 10:11 Foo.bar\n' |
| + 'package:stack_trace/stack_trace.dart 10:11 Bar.baz\n' |
| + 'dart:core 10:11 Zip.zap\n' |
| + 'user/code.dart 10:11 Zop.zoop') |
| + ]); |
| + |
| + expect(chain.terse.toString(), equals( |
| + 'dart:core Bar.baz\n' |
| + 'user/code.dart 10:11 Bang.qux\n' |
| + 'dart:core Zop.zoop\n' |
| + '===== asynchronous gap ===========================\n' |
| + 'user/code.dart 10:11 Bang.qux\n' |
| + 'dart:core Zip.zap\n' |
| + 'user/code.dart 10:11 Zop.zoop\n')); |
| + }); |
| + |
| + test('eliminates internal-only traces', () { |
| + var chain = new Chain([ |
| + new Trace.parse( |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz'), |
| + new Trace.parse( |
| + 'dart:core 10:11 Foo.bar\n' |
| + 'package:stack_trace/stack_trace.dart 10:11 Bar.baz\n' |
| + 'dart:core 10:11 Zip.zap'), |
| + new Trace.parse( |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz') |
| + ]); |
| + |
| + expect(chain.terse.toString(), equals( |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core Bar.baz\n' |
| + '===== asynchronous gap ===========================\n' |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core Bar.baz\n')); |
| + }); |
| + }); |
| + |
| + test('Chain.toTrace eliminates asynchronous gaps', () { |
| + var trace = new Chain([ |
| + new Trace.parse( |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz'), |
| + new Trace.parse( |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz') |
| + ]).toTrace(); |
| + |
| + expect(trace.toString(), equals( |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz\n' |
| + 'user/code.dart 10:11 Foo.bar\n' |
| + 'dart:core 10:11 Bar.baz\n')); |
| + }); |
| + |
| + group('Chain.track(Future)', () { |
| + test('associates the current chain with a manually-reported exception with ' |
| + 'a stack trace', () { |
| + var trace = new Trace.current(); |
| + return capture(() { |
| + inMicrotask(() => trackedErrorFuture(trace)); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + |
| + // The first trace is the trace that was manually reported for the |
| + // error. |
| + expect(chain.traces.first.toString(), equals(trace.toString())); |
| + |
| + // The second trace is the trace that was captured when [Chain.track] |
| + // was called. |
| + expect(chain.traces[1].frames.first, |
| + frameMember(startsWith('trackedErrorFuture'))); |
| + |
| + // The third trace is the automatically-captured trace from when the |
| + // microtask was scheduled. |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('associates the current chain with a manually-reported exception with ' |
| + 'no stack trace', () { |
| + return capture(() { |
| + inMicrotask(() => trackedErrorFuture()); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + |
| + // The first trace is the one captured by |
| + // [StackZoneSpecification.trackFuture], which should contain only |
| + // stack_trace and dart: frames. |
| + expect(chain.traces.first.frames, |
| + everyElement(frameLibrary(isNot(contains('chain_test'))))); |
| + |
| + expect(chain.traces[1].frames.first, |
| + frameMember(startsWith('trackedErrorFuture'))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('forwards the future value within Chain.capture()', () { |
| + Chain.capture(() { |
| + expect(Chain.track(new Future.value('value')), |
| + completion(equals('value'))); |
| + |
| + var trace = new Trace.current(); |
| + expect(Chain.track(new Future.error('error', trace)) |
| + .catchError((e, stackTrace) { |
| + expect(e, equals('error')); |
| + expect(stackTrace.toString(), equals(trace.toString())); |
| + }), completes); |
| + }); |
| + }); |
| + |
| + test('forwards the future value outside of Chain.capture()', () { |
| + expect(Chain.track(new Future.value('value')), |
| + completion(equals('value'))); |
| + |
| + var trace = new Trace.current(); |
| + expect(Chain.track(new Future.error('error', trace)) |
| + .catchError((e, stackTrace) { |
| + expect(e, equals('error')); |
| + expect(stackTrace.toString(), equals(trace.toString())); |
| + }), completes); |
| + }); |
| + }); |
| + |
| + group('Chain.track(Stream)', () { |
| + test('associates the current chain with a manually-reported exception with ' |
| + 'a stack trace', () { |
| + var trace = new Trace.current(); |
| + return capture(() { |
| + inMicrotask(() => trackedErrorStream(trace).listen(null)); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + expect(chain.traces.first.toString(), equals(trace.toString())); |
| + expect(chain.traces[1].frames.first, |
| + frameMember(startsWith('trackedErrorStream'))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('associates the current chain with a manually-reported exception with ' |
| + 'no stack trace', () { |
| + return capture(() { |
| + inMicrotask(() => trackedErrorStream().listen(null)); |
| + }).then((chain) { |
| + expect(chain.traces, hasLength(3)); |
| + expect(chain.traces.first.frames, |
| + everyElement(frameLibrary(isNot(contains('chain_test'))))); |
| + expect(chain.traces[1].frames.first, |
| + frameMember(startsWith('trackedErrorStream'))); |
| + expect(chain.traces[2].frames, |
| + contains(frameMember(startsWith('inMicrotask')))); |
| + }); |
| + }); |
| + |
| + test('forwards stream values within Chain.capture()', () { |
| + Chain.capture(() { |
| + var controller = new StreamController() |
| + ..add(1)..add(2)..add(3)..close(); |
| + expect(Chain.track(controller.stream).toList(), |
| + completion(equals([1, 2, 3]))); |
| + |
| + var trace = new Trace.current(); |
| + controller = new StreamController()..addError('error', trace); |
| + expect(Chain.track(controller.stream).toList() |
| + .catchError((e, stackTrace) { |
| + expect(e, equals('error')); |
| + expect(stackTrace.toString(), equals(trace.toString())); |
| + }), completes); |
| + }); |
| + }); |
| + |
| + test('forwards stream values outside of Chain.capture()', () { |
| + Chain.capture(() { |
| + var controller = new StreamController() |
| + ..add(1)..add(2)..add(3)..close(); |
| + expect(Chain.track(controller.stream).toList(), |
| + completion(equals([1, 2, 3]))); |
| + |
| + var trace = new Trace.current(); |
| + controller = new StreamController()..addError('error', trace); |
| + expect(Chain.track(controller.stream).toList() |
| + .catchError((e, stackTrace) { |
| + expect(e, equals('error')); |
| + expect(stackTrace.toString(), equals(trace.toString())); |
| + }), completes); |
| + }); |
| + }); |
| + }); |
| +} |
| + |
| +/// Runs [callback] in a microtask callback. |
| +void inMicrotask(callback()) => scheduleMicrotask(callback); |
| + |
| +/// Runs [callback] in a one-shot timer callback. |
| +void inOneShotTimer(callback()) => Timer.run(callback); |
| + |
| +/// Runs [callback] once in a periodic timer callback. |
| +void inPeriodicTimer(callback()) { |
| + var count = 0; |
| + new Timer.periodic(new Duration(milliseconds: 1), (timer) { |
| + count++; |
| + if (count != 5) return; |
| + timer.cancel(); |
| + callback(); |
| + }); |
| +} |
| + |
| +/// Runs [callback] within a long asynchronous Future chain. |
| +void inFutureChain(callback()) { |
| + new Future(() {}) |
| + .then((_) => new Future(() {})) |
| + .then((_) => new Future(() {})) |
| + .then((_) => new Future(() {})) |
| + .then((_) => new Future(() {})) |
| + .then((_) => callback()) |
| + .then((_) => new Future(() {})); |
| +} |
| + |
| +/// Returns a Future that completes to an error and is wrapped in [Chain.track]. |
| +/// |
| +/// If [trace] is passed, it's used as the stack trace for the error. |
| +Future trackedErrorFuture([StackTrace trace]) { |
| + var completer = new Completer(); |
| + completer.completeError('error', trace); |
| + return Chain.track(completer.future); |
| +} |
| + |
| +/// Returns a Stream that emits an error and is wrapped in [Chain.track]. |
| +/// |
| +/// If [trace] is passed, it's used as the stack trace for the error. |
| +Stream trackedErrorStream([StackTrace trace]) { |
| + var controller = new StreamController(); |
| + controller.addError('error', trace); |
| + return Chain.track(controller.stream); |
| +} |
| + |
| +/// Runs [callback] within [asyncFn], then converts any errors raised into a |
| +/// [Chain] with [Chain.forTrace]. |
| +Future<Chain> chainForTrace(asyncFn(callback()), callback()) { |
| + var completer = new Completer(); |
| + asyncFn(() { |
| + // We use `new Future.value().then(...)` here as opposed to [new Future] or |
| + // [new Future.sync] because those methods don't pass the exception through |
| + // the zone specification before propagating it, so there's no chance to |
| + // attach a chain to its stack trace. See issue 15105. |
| + new Future.value().then((_) => callback()) |
| + .catchError(completer.completeError); |
| + }); |
| + return completer.future |
| + .catchError((_, stackTrace) => new Chain.forTrace(stackTrace)); |
| +} |
| + |
| +/// Runs [callback] in a [Chain.capture] zone and returns a Future that |
| +/// completes to the stack chain for an error thrown by [callback]. |
| +/// |
| +/// [callback] is expected to throw the string `"error"`. |
| +Future<Chain> capture(callback()) { |
| + var completer = new Completer<Chain>(); |
| + Chain.capture(callback, onError: (error, chain) { |
| + expect(error, equals('error')); |
| + completer.complete(chain); |
| + }); |
| + return completer.future; |
| +} |