diff --git a/app/lib/package/api_export/api_exporter.dart b/app/lib/package/api_export/api_exporter.dart index 4215cd9637..bd0a7da03b 100644 --- a/app/lib/package/api_export/api_exporter.dart +++ b/app/lib/package/api_export/api_exporter.dart @@ -13,6 +13,7 @@ import 'package:pub_dev/frontend/handlers/atom_feed.dart'; import 'package:pub_dev/service/security_advisories/backend.dart'; import 'package:pub_dev/shared/exceptions.dart'; import 'package:pub_dev/shared/parallel_foreach.dart'; +import 'package:pub_dev/task/clock_control.dart'; import '../../search/backend.dart'; import '../../shared/datastore.dart'; @@ -308,7 +309,8 @@ final class ApiExporter { seen.removeWhere((_, updated) => updated.isBefore(since)); // Wait until aborted or 10 minutes before scanning again! - await abort.future.timeout(Duration(minutes: 10), onTimeout: () => null); + await abort.future + .timeoutWithClock(Duration(minutes: 10), onTimeout: () => null); } } diff --git a/app/lib/service/services.dart b/app/lib/service/services.dart index 8068525db1..c34bdaeb10 100644 --- a/app/lib/service/services.dart +++ b/app/lib/service/services.dart @@ -300,7 +300,7 @@ Future _withPubServices(FutureOr Function() fn) async { // Create a zone-local flag to indicate that services setup has been completed. return await fork( - () => Zone.current.fork(zoneValues: { + () async => Zone.current.fork(zoneValues: { _pubDevServicesInitializedKey: true, }).run( () async { diff --git a/app/lib/task/backend.dart b/app/lib/task/backend.dart index 1e61d74dd1..2a899d1550 100644 --- a/app/lib/task/backend.dart +++ b/app/lib/task/backend.dart @@ -36,6 +36,7 @@ import 'package:pub_dev/shared/versions.dart' acceptedRuntimeVersions; import 'package:pub_dev/shared/versions.dart' as shared_versions show runtimeVersion; +import 'package:pub_dev/task/clock_control.dart'; import 'package:pub_dev/task/cloudcompute/cloudcompute.dart'; import 'package:pub_dev/task/global_lock.dart'; import 'package:pub_dev/task/handlers.dart'; @@ -138,7 +139,7 @@ class TaskBackend { st, ); // Sleep 5 minutes to reduce risk of degenerate behavior - await Future.delayed(Duration(minutes: 5)); + await clock.delayed(Duration(minutes: 5)); } } } catch (e, st) { @@ -176,7 +177,7 @@ class TaskBackend { st, ); // Sleep 5 minutes to reduce risk of degenerate behavior - await Future.delayed(Duration(minutes: 5)); + await clock.delayed(Duration(minutes: 5)); } } } catch (e, st) { @@ -349,7 +350,8 @@ class TaskBackend { seen.removeWhere((_, updated) => updated.isBefore(since)); // Wait until aborted or 10 minutes before scanning again! - await abort.future.timeout(Duration(minutes: 10), onTimeout: () => null); + await abort.future + .timeoutWithClock(Duration(minutes: 10), onTimeout: () => null); } } diff --git a/app/test/task/fake_time.dart b/app/lib/task/clock_control.dart similarity index 52% rename from app/test/task/fake_time.dart rename to app/lib/task/clock_control.dart index fc2b44b6a8..784f625abd 100644 --- a/app/test/task/fake_time.dart +++ b/app/lib/task/clock_control.dart @@ -1,101 +1,108 @@ -// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// Copyright (c) 2025, 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. import 'dart:async'; -import 'dart:math'; import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; -// TODO(jonasfj): Document this concept, maybe give it a better name and see if -// we can publish it as a separate package. Maybe, it should be -// called FakeClock instead, TimeMachine, or maybe Tardis, or -// TimeTraveling.run((timeMachine) => timeMachine.travel(..)) -// Or something else clever, without being too clever! -// Or maybe we should rename _TravelingTimer to _FakeTimer. - -abstract final class FakeTime { - FakeTime._(); - - Future elapse({ - int days = 0, - int hours = 0, - int minutes = 0, - int seconds = 0, - int milliseconds = 0, - int microseconds = 0, - }) => - elapseTime(Duration( - days: days, - hours: hours, - minutes: minutes, - seconds: seconds, - milliseconds: milliseconds, - microseconds: microseconds, - )); - - void elapseSync({ - int days = 0, - int hours = 0, - int minutes = 0, - int seconds = 0, - int milliseconds = 0, - int microseconds = 0, - }) => - elapseTimeSync(Duration( - days: days, - hours: hours, - minutes: minutes, - seconds: seconds, - milliseconds: milliseconds, - microseconds: microseconds, - )); - - Future elapseTime(Duration duration); - Future elapseTo(DateTime futureTime); - void elapseTimeSync(Duration duration); - void elapseToSync(DateTime futureTime); +final _clockCtrlKey = #_clockCtrlKey; + +Future withClockControl( + FutureOr Function(ClockController clockCtrl) fn, { + DateTime? initialTime, +}) async { + final now = clock.now(); + initialTime ??= now; + + final clockCtrl = ClockController._(clock.now, initialTime.difference(now)); + return await runZoned( + () => withClock( + Clock(clockCtrl._controlledTime), + () async => await fn(clockCtrl), + ), + zoneValues: {_clockCtrlKey: clockCtrl}, + ); +} - /// Elapse fake time until [condition] returns `true`. +extension FutureTimeout on Future { + /// Create a [Future] that will timeout after [timeLimit], and controllable + /// by [ClockController]. /// - /// Throws [TimeoutException], if [condition] is not satisfied with-in - /// [timeout], - Future elapseUntil( - FutureOr Function() condition, { - Duration timeout, - Duration minimumStep, - }); + /// This is the same as [timeout], except [ClockController.elapse] will also + /// trigger this timeout, but will not trigger [timeout]. + /// + /// Use this if you need a timeout that will be fired when [clock] is advanced + /// during testing. In production this should have no effect. + Future timeoutWithClock( + Duration timeLimit, { + FutureOr Function()? onTimeout, + }) { + final clockCtrl = Zone.current[_clockCtrlKey]; + if (clockCtrl is ClockController) { + final c = Completer(); + final timer = clockCtrl._createTimer(timeLimit, () { + if (!c.isCompleted) { + if (onTimeout != null) { + c.complete(onTimeout()); + } else { + c.completeError(TimeoutException('Timeout exceeded', timeLimit)); + } + } + }); + scheduleMicrotask(() async { + try { + final value = await this; + if (!c.isCompleted) { + c.complete(value); + } + } catch (error, stackTrace) { + if (!c.isCompleted) { + c.completeError(error, stackTrace); + } + } finally { + timer.cancel(); + } + }); + return c.future; + } else { + return timeout(timeLimit, onTimeout: onTimeout); + } + } +} - static Future run( - FutureOr Function(FakeTime fakeTime) fn, { - DateTime? initialTime, - }) async { - final now = clock.now(); - initialTime ??= now; - - final tm = _FakeTime(clock.now, initialTime.difference(now)); - return await runZoned( - () => withClock(Clock(tm._fakeTime), () async => await fn(tm)), - zoneSpecification: ZoneSpecification( - createTimer: tm._createTimer, - createPeriodicTimer: tm._createPeriodicTimer, - scheduleMicrotask: tm._scheduleMicrotask, - ), - ); +extension ClockDelayed on Clock { + /// Create a [Future] that is resolved after [delay], and controllable by + /// [ClockController]. + /// + /// This is the same as [Future.delayed], except [ClockController.elapse] will + /// also resolve this future, but will not resolve [Future.delayed]. + /// + /// Use this if you need a delay that will be fired when [clock] is advanced + /// during testing. In production this should have no effect. + Future delayed(Duration delay) { + final clockCtrl = Zone.current[_clockCtrlKey]; + if (clockCtrl is ClockController) { + final c = Completer(); + clockCtrl._createTimer(delay, c.complete); + return c.future; + } else { + return Future.delayed(delay); + } } } -final class _FakeTime extends FakeTime { +final class ClockController { final DateTime Function() _originalTime; Duration _offset; - _FakeTime( + ClockController._( this._originalTime, this._offset, - ) : super._(); + ); - DateTime _fakeTime() => _originalTime().add(_offset); + DateTime _controlledTime() => _originalTime().add(_offset); /// [PriorityQueue] of pending timers. /// @@ -108,56 +115,20 @@ final class _FakeTime extends FakeTime { t2._elapsesAtInFakeTime.microsecondsSinceEpoch, ); - /// [Timer] created in [_TravelingTimer._parent] zone, for the first pending + /// [Timer] created in [_TravelingTimer._zone] zone, for the first pending /// timer in [_pendingTimers]. /// /// This value is `null` when [_pendingTimers] is empty. Timer? _timerForFirstPendingTimer; - Timer _createTimer( - Zone self, - ZoneDelegate parent, - Zone zone, + _TravelingTimer _createTimer( Duration duration, void Function() fn, ) { final timer = _TravelingTimer( owner: this, - createdInFakeTime: _fakeTime(), - parent: parent, - zone: zone, - duration: duration, - trigger: fn, - ); - - _pendingTimers.add(timer); - - // If the newly added [timer] is the first timer in the queue, then we have - // to create a new [_timerForFirstPendingTimer] timer. - if (_pendingTimers.first == timer) { - _timerForFirstPendingTimer?.cancel(); - _timerForFirstPendingTimer = timer._parent.createTimer( - timer._zone, - timer._duration, - _triggerPendingTimers, - ); - } - - return timer; - } - - Timer _createPeriodicTimer( - Zone self, - ZoneDelegate parent, - Zone zone, - Duration duration, - void Function(Timer timer) fn, - ) { - final timer = _TravelingTimer.periodic( - owner: this, - createdInFakeTime: _fakeTime(), - parent: parent, - zone: zone, + createdInControlledTime: _controlledTime(), + zone: Zone.current, duration: duration, trigger: fn, ); @@ -168,8 +139,7 @@ final class _FakeTime extends FakeTime { // to create a new [_timerForFirstPendingTimer] timer. if (_pendingTimers.first == timer) { _timerForFirstPendingTimer?.cancel(); - _timerForFirstPendingTimer = timer._parent.createTimer( - timer._zone, + _timerForFirstPendingTimer = timer._zone.createTimer( timer._duration, _triggerPendingTimers, ); @@ -179,7 +149,7 @@ final class _FakeTime extends FakeTime { } /// This will cancel [_timerForFirstPendingTimer] if active, and trigger all - /// [_pendingTimers] that are pending according to [_fakeTime]. + /// [_pendingTimers] that are pending according to [_controlledTime]. /// /// This will always create a new [Timer] for [_timerForFirstPendingTimer], /// recovering from any elapse of time, whether as a result of waiting for a @@ -189,53 +159,28 @@ final class _FakeTime extends FakeTime { final actualTimer = _timerForFirstPendingTimer; _timerForFirstPendingTimer = null; - final fakeNow = _fakeTime(); + final controlledNow = _controlledTime(); // Take all pending timers that are scheduled to be triggered now. - // Notice that we take timers that are not after [fakeNow], that means that - // if we move time forward to exactly the point in time where a timer + // Notice that we take timers that are not after [controlledNow], that means + // that if we move time forward to exactly the point in time where a timer // becomes we will trigger it. final triggeredTimers = <_TravelingTimer>[]; while (_pendingTimers.isNotEmpty && - !_pendingTimers.first._elapsesAtInFakeTime.isAfter(fakeNow)) { + !_pendingTimers.first._elapsesAtInFakeTime.isAfter(controlledNow)) { triggeredTimers.add(_pendingTimers.removeFirst()); } - // Increase ticks and set active false as necessary for [triggeredTimers]. - for (final triggeredTimer in triggeredTimers) { - // Increase ticks with the number of missed ticks, if duration is not zero - if (triggeredTimer._duration > Duration.zero) { - final delay = _fakeTime().difference(triggeredTimer._createdInFakeTime); - final durationNs = triggeredTimer._duration.inMicroseconds; - final ticks = (delay.inMicroseconds / durationNs).round(); - triggeredTimer._tick = max( - triggeredTimer._tick + 1, - ticks - triggeredTimer._tick, - ); - } else { - // Always increase tick by at-least one - triggeredTimer._tick += 1; - } - - // Insert [triggeredTimer] for it to be scheduled again - if (triggeredTimer._isPeriodic) { - _pendingTimers.add(triggeredTimer); - } else { - triggeredTimer._active = false; - } - } - // Schedule the next actual timer, if [_pendingTimers] is not empty if (_pendingTimers.isNotEmpty) { final nextTimer = _pendingTimers.first; - var delay = nextTimer._elapsesAtInFakeTime.difference(_fakeTime()); + var delay = nextTimer._elapsesAtInFakeTime.difference(_controlledTime()); if (delay.isNegative) { delay = Duration.zero; } - _timerForFirstPendingTimer = nextTimer._parent.createTimer( - nextTimer._zone, + _timerForFirstPendingTimer = nextTimer._zone.createTimer( delay, _triggerPendingTimers, ); @@ -248,22 +193,15 @@ final class _FakeTime extends FakeTime { // Trigger the callback for the pending timer. for (final triggeredTimer in triggeredTimers) { - // Skip running periodic timers if they have been cancelled. - // TODO: review/refactor trigger sequence so that this doesn't happen - if (triggeredTimer._isPeriodic && !triggeredTimer.isActive) { - continue; - } try { - triggeredTimer._parent.runUnary( - triggeredTimer._zone, + triggeredTimer._zone.runUnary( triggeredTimer._trigger, triggeredTimer, ); } catch (error, stackTrace) { // Documentation is unclear about whether or not an exception can be // thrown here. - triggeredTimer._parent.handleUncaughtError( - triggeredTimer._zone, + triggeredTimer._zone.handleUncaughtError( error, stackTrace, ); @@ -273,17 +211,9 @@ final class _FakeTime extends FakeTime { void _cancelPendingTimer(_TravelingTimer timer) { if (_pendingTimers.isEmpty) { - // When cancelled a timer becomes in active, and it will never call - // [_cancelPendingTimer] again. - assert(!timer._active); - assert(false); - timer._active = false; // for sanity only return; } - // Mark [timer] as no-longer active - timer._active = false; - // If the timer being cancelled is the next timer to run if (_pendingTimers.first == timer) { // Remove the timer, and cancel the actual timer. @@ -295,13 +225,14 @@ final class _FakeTime extends FakeTime { if (_pendingTimers.isNotEmpty) { final nextTimer = _pendingTimers.first; - var delay = nextTimer._elapsesAtInFakeTime.difference(_fakeTime()); + var delay = nextTimer._elapsesAtInFakeTime.difference( + _controlledTime(), + ); if (delay.isNegative) { delay = Duration.zero; } - _timerForFirstPendingTimer = nextTimer._parent.createTimer( - nextTimer._zone, + _timerForFirstPendingTimer = nextTimer._zone.createTimer( delay, _triggerPendingTimers, ); @@ -316,34 +247,68 @@ final class _FakeTime extends FakeTime { } // If [timer] isn't the next timer to run, then we just removed it. - final removed = _pendingTimers.remove(timer); - assert(removed); // check that it was removed. + _pendingTimers.remove(timer); } - @override + Future elapse({ + int days = 0, + int hours = 0, + int minutes = 0, + int seconds = 0, + int milliseconds = 0, + int microseconds = 0, + }) => + elapseTime(Duration( + days: days, + hours: hours, + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + microseconds: microseconds, + )); + + void elapseSync({ + int days = 0, + int hours = 0, + int minutes = 0, + int seconds = 0, + int milliseconds = 0, + int microseconds = 0, + }) => + elapseTimeSync(Duration( + days: days, + hours: hours, + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + microseconds: microseconds, + )); + Future elapseTime(Duration duration) { if (duration.isNegative) { throw ArgumentError.value( duration, 'duration', - 'FakeTime.elapseTime can only move forward in time', + 'ClockController.elapseTime can only move forward in time', ); } - return _elapseTo(_fakeTime().add(duration)); + return _elapseTo(_controlledTime().add(duration)); } - @override Future elapseTo(DateTime futureTime) { - if (_fakeTime().isAfter(futureTime)) { + if (_controlledTime().isAfter(futureTime)) { throw StateError( - 'FakeTime.elapseTo(futureTime) cannot travel backwards in time, ' + 'ClockController.elapseTo(futureTime) cannot travel backwards in time, ' 'futureTime > now cannot be allowed', ); } return _elapseTo(futureTime); } - @override + /// Elapse time until [condition] returns `true`. + /// + /// Throws [TimeoutException], if [condition] is not satisfied with-in + /// [timeout], Future elapseUntil( FutureOr Function() condition, { Duration? timeout, @@ -363,7 +328,7 @@ final class _FakeTime extends FakeTime { // Jump into the future, until the point in time that the next timer is // pending. final nextTimerElapsesAt = _pendingTimers.first._elapsesAtInFakeTime; - _offset += nextTimerElapsesAt.difference(_fakeTime()); + _offset += nextTimerElapsesAt.difference(_controlledTime()); // Trigger all timers that are pending, this cancels any actual timer // and creates a new pending timer. @@ -378,7 +343,7 @@ final class _FakeTime extends FakeTime { if (deadline != null) { // Jump into the desired future point in time. - _offset += deadline.difference(_fakeTime()); + _offset += deadline.difference(_controlledTime()); // Ensure that we cancel the current actual timer, trigger any pending // timers, and create a new actual timer. @@ -389,12 +354,25 @@ final class _FakeTime extends FakeTime { if (!await condition()) { throw TimeoutException( - 'Condition given to FakeTime.elapseUntil was not satisfied' + 'Condition given to ClockController.elapseUntil was not satisfied' ' before timeout: $timeout', ); } } + /// Expect [condition] to return `true` until [duration] has elapsed. + Future expectUntil( + FutureOr Function() condition, Duration duration) async { + try { + await elapseUntil(() async { + return !await condition(); + }, timeout: duration); + throw AssertionError('Condition failed before $duration expired'); + } on TimeoutException { + return; + } + } + /// Elapse time until [futureTime]. /// /// This is an implementation of [elapseTo] without checks that we are not @@ -409,7 +387,7 @@ final class _FakeTime extends FakeTime { // Jump into the future, until the point in time that the next timer is // pending. final nextTimerElapsesAt = _pendingTimers.first._elapsesAtInFakeTime; - _offset += nextTimerElapsesAt.difference(_fakeTime()); + _offset += nextTimerElapsesAt.difference(_controlledTime()); // Trigger all timers that are pending, this cancels any actual timer // and creates a new pending timer. @@ -419,19 +397,18 @@ final class _FakeTime extends FakeTime { await _waitForMicroTasks(); // Jump into the desired future point in time. - _offset += futureTime.difference(_fakeTime()); + _offset += futureTime.difference(_controlledTime()); // Ensure that we cancel the current actual timer, trigger any pending // timers, and create a new actual timer. _triggerPendingTimers(); } - @override void elapseTimeSync(Duration duration) { if (duration.isNegative) { throw ArgumentError.value( duration, 'duration', - 'FakeTime.elapseTimeSync can only move forward in time', + 'ClockController.elapseTimeSync can only move forward in time', ); } _offset += duration; @@ -440,63 +417,34 @@ final class _FakeTime extends FakeTime { _triggerPendingTimers(); } - @override void elapseToSync(DateTime futureTime) { - if (_fakeTime().isAfter(futureTime)) { + final controlledNow = _controlledTime(); + if (controlledNow.isAfter(futureTime)) { throw StateError( 'FakeTime.elapseToSync(futureTime) cannot travel backwards in ' 'time, futureTime > now cannot be allowed', ); } - _offset += futureTime.difference(_fakeTime()); + _offset += futureTime.difference(controlledNow); // Ensure that we cancel the current actual timer, trigger any pending // timers, and create a new actual timer. _triggerPendingTimers(); } - int _pendingMicroTasks = 0; - Completer _microTasksDone = Completer.sync()..complete(); - /// Wait for all scheduled microtasks to be done. Future _waitForMicroTasks() async { - await _microTasksDone.future; - } - - void _scheduleMicrotask( - Zone self, - ZoneDelegate parent, - Zone zone, - void Function() fn, - ) { - if (_pendingMicroTasks == 0) { - _microTasksDone = Completer.sync(); - } - _pendingMicroTasks += 1; - parent.scheduleMicrotask(zone, () { - // TODO: Test if a microtask scheduled inside a microtask also gets here! - try { - fn(); - } finally { - _pendingMicroTasks -= 1; - if (_pendingMicroTasks == 0) { - _microTasksDone.complete(); - } - } - }); + await Future.delayed(Duration(microseconds: 0)); } } -class _TravelingTimer implements Timer { - /// [_FakeTime] to which this [_TravelingTimer] belongs. - final _FakeTime _owner; +final class _TravelingTimer { + /// [ClockController] to which this [_TravelingTimer] belongs. + final ClockController _owner; /// [DateTime] when this [_TravelingTimer] was created in - /// [_FakeTime._fakeTime]. - final DateTime _createdInFakeTime; - - /// Parent zone, for creation of timers. - final ZoneDelegate _parent; + /// [ClockController._controlledTime]. + final DateTime _createdInControlledTime; /// Zone, for creation of timers. final Zone _zone; @@ -505,63 +453,23 @@ class _TravelingTimer implements Timer { final Duration _duration; /// Callback to be invoked when this [_TravelingTimer] is triggered. - final void Function(Timer timer) _trigger; - - /// `true`, if this [_TravelingTimer] is periodic. - final bool _isPeriodic; - - /// `true`, if this [_TravelingTimer] is still pending and have not been - /// cancelled, or triggered (if this is not a periodic timer). - bool _active = true; - - /// Number of times this [_TravelingTimer] should have been triggered. - int _tick = 0; + final void Function(_TravelingTimer timer) _trigger; /// [DateTime] when this [_TravelingTimer] is supposed to be triggered, - /// measured in [_FakeTime._fakeTime]. - DateTime get _elapsesAtInFakeTime => - _createdInFakeTime.add(_duration * (1 + tick)); + /// measured in [ClockController._controlledTime]. + DateTime get _elapsesAtInFakeTime => _createdInControlledTime.add(_duration); _TravelingTimer({ - required _FakeTime owner, - required DateTime createdInFakeTime, - required ZoneDelegate parent, + required ClockController owner, + required DateTime createdInControlledTime, required Zone zone, required Duration duration, required void Function() trigger, }) : _owner = owner, - _createdInFakeTime = createdInFakeTime, - _parent = parent, - _zone = zone, - _duration = duration, - _trigger = ((_) => trigger()), - _isPeriodic = false; - - _TravelingTimer.periodic({ - required _FakeTime owner, - required DateTime createdInFakeTime, - required ZoneDelegate parent, - required Zone zone, - required Duration duration, - required void Function(Timer timer) trigger, - }) : _owner = owner, - _createdInFakeTime = createdInFakeTime, - _parent = parent, + _createdInControlledTime = createdInControlledTime, _zone = zone, _duration = duration, - _trigger = trigger, - _isPeriodic = true; + _trigger = ((_) => trigger()); - @override - bool get isActive => _active; - - @override - int get tick => _tick; - - @override - void cancel() { - if (isActive) { - _owner._cancelPendingTimer(this); - } - } + void cancel() => _owner._cancelPendingTimer(this); } diff --git a/app/lib/task/cloudcompute/fakecloudcompute.dart b/app/lib/task/cloudcompute/fakecloudcompute.dart index e08a4c1bb8..4b7703929c 100644 --- a/app/lib/task/cloudcompute/fakecloudcompute.dart +++ b/app/lib/task/cloudcompute/fakecloudcompute.dart @@ -10,6 +10,7 @@ import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:pub_dev/frontend/static_files.dart'; +import 'package:pub_dev/task/clock_control.dart'; import 'cloudcompute.dart'; @@ -75,7 +76,8 @@ final class FakeCloudCompute extends CloudCompute { } @override - Stream listInstances() => Stream.fromIterable(_instances); + Stream listInstances() => + Stream.fromIterable([..._instances]); @override Future delete(String zone, String instanceName) async { @@ -90,7 +92,7 @@ final class FakeCloudCompute extends CloudCompute { // Let's make the operation take a second, and then remove the instance! _log.info('Deleting instance "$instanceName"'); - await Future.delayed(Duration(seconds: 1)); + await clock.delayed(Duration(seconds: 1)); final removed = _instances .where((i) => i.instanceName == instanceName && i.zone == zone) .toList(); diff --git a/app/lib/task/global_lock.dart b/app/lib/task/global_lock.dart index 37f480a8e4..a9e36accf5 100644 --- a/app/lib/task/global_lock.dart +++ b/app/lib/task/global_lock.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:clock/clock.dart'; import 'package:logging/logging.dart' show Logger; import 'package:pub_dev/shared/datastore.dart'; +import 'package:pub_dev/task/clock_control.dart'; import 'package:pub_dev/task/global_lock_models.dart'; import 'package:ulid/ulid.dart' show Ulid; @@ -58,7 +59,7 @@ class GlobalLock { // refresh, before it's truly expired. // Wait for done or delay - await done.future.timeout(delay, onTimeout: () => null); + await done.future.timeoutWithClock(delay, onTimeout: () => null); // Try to refresh, if claim is still valid and we're not done. if (c.valid && !done.isCompleted) { @@ -158,7 +159,7 @@ class GlobalLock { delay = _expiration * 0.1; } // Wait for delay or abort - await abort.future.timeout(delay, onTimeout: () => null); + await abort.future.timeoutWithClock(delay, onTimeout: () => null); } e = await _tryClaimOrGet(claimId); } diff --git a/app/lib/task/scheduler.dart b/app/lib/task/scheduler.dart index c100d7d13d..3e036fbfa2 100644 --- a/app/lib/task/scheduler.dart +++ b/app/lib/task/scheduler.dart @@ -11,6 +11,7 @@ import 'package:pub_dev/shared/configuration.dart'; import 'package:pub_dev/shared/datastore.dart'; import 'package:pub_dev/shared/utils.dart'; import 'package:pub_dev/shared/versions.dart' show runtimeVersion; +import 'package:pub_dev/task/clock_control.dart'; import 'package:pub_dev/task/cloudcompute/cloudcompute.dart'; import 'package:pub_dev/task/global_lock.dart'; import 'package:pub_dev/task/models.dart'; @@ -39,7 +40,7 @@ Future schedule( // Await a micro task to ensure consistent behavior await Future.microtask(() {}); } else { - await abort.future.timeout(delay, onTimeout: () => null); + await abort.future.timeoutWithClock(delay, onTimeout: () => null); } } diff --git a/app/test/package/api_export/api_exporter_test.dart b/app/test/package/api_export/api_exporter_test.dart index 76b0c0926d..b9f0aa07ba 100644 --- a/app/test/package/api_export/api_exporter_test.dart +++ b/app/test/package/api_export/api_exporter_test.dart @@ -23,7 +23,6 @@ import 'package:test/test.dart'; import '../../shared/test_models.dart'; import '../../shared/test_services.dart'; -import '../../task/fake_time.dart'; final _log = Logger('api_export.test'); @@ -43,12 +42,12 @@ final _testProfile = TestProfile( ); void main() { - testWithFakeTime('synchronizeExportedApi()', + testWithProfile('synchronizeExportedApi()', testProfile: _testProfile, expectedLogMessages: [ 'SHOUT Deleting object from public bucket: "packages/bar-2.0.0.tar.gz".', 'SHOUT Deleting object from public bucket: "packages/bar-3.0.0.tar.gz".', - ], (fakeTime) async { + ], fn: () async { // Since we want to verify post-upload tasks triggering API exporter, // we cannot use an isolated instance, we need to use the same setup. // However, for better control and consistency, we can remove all the @@ -59,20 +58,19 @@ void main() { await _deleteAll(bucket); await _testExportedApiSynchronization( - fakeTime, bucket, apiExporter!.synchronizeExportedApi, ); }); - testWithFakeTime( + testWithProfile( 'apiExporter.start()', expectedLogMessages: [ 'SHOUT Deleting object from public bucket: "packages/bar-2.0.0.tar.gz".', 'SHOUT Deleting object from public bucket: "packages/bar-3.0.0.tar.gz".', ], testProfile: _testProfile, - (fakeTime) async { + fn: () async { // Since we want to verify post-upload tasks triggering API exporter, // we cannot use an isolated instance, we need to use the same setup. // However, for better control and consistency, we can remove all the @@ -87,9 +85,8 @@ void main() { await apiExporter!.start(); await _testExportedApiSynchronization( - fakeTime, bucket, - () async => await fakeTime.elapse(minutes: 15), + () async => await clockControl.elapse(minutes: 15), ); await apiExporter!.stop(); @@ -106,7 +103,6 @@ Future _deleteAll(Bucket bucket) async { } Future _testExportedApiSynchronization( - FakeTime fakeTime, Bucket bucket, Future Function() synchronize, ) async { @@ -341,7 +337,7 @@ Future _testExportedApiSynchronization( { // Elapse time before moderating package, because exported-api won't delete // recently created files as a guard against race conditions. - fakeTime.elapseSync(days: 1); + clockControl.elapseSync(days: 1); await withRetryPubApiClient( authToken: createFakeServiceAccountToken(email: 'admin@pub.dev'), @@ -422,7 +418,7 @@ Future _testExportedApiSynchronization( { // Elapse time before moderating package, because exported-api won't delete // recently created files as a guard against race conditions. - fakeTime.elapseSync(days: 1); + clockControl.elapseSync(days: 1); await withRetryPubApiClient( authToken: createFakeServiceAccountToken(email: 'admin@pub.dev'), diff --git a/app/test/package/api_export/exported_api_test.dart b/app/test/package/api_export/exported_api_test.dart index 513fbd0c6c..9b32f5446c 100644 --- a/app/test/package/api_export/exported_api_test.dart +++ b/app/test/package/api_export/exported_api_test.dart @@ -189,7 +189,7 @@ void main() { ); }); - testWithFakeTime('ExportedApi.garbageCollect()', (fakeTime) async { + testWithProfile('ExportedApi.garbageCollect()', fn: () async { await storageService.createBucket('exported-api'); final bucket = storageService.bucket('exported-api'); final exportedApi = ExportedApi(storageService, bucket); @@ -208,7 +208,7 @@ void main() { ); // Check that GC after 10 mins won't delete a package we don't recognize - fakeTime.elapseSync(minutes: 10); + clockControl.elapseSync(minutes: 10); await exportedApi.garbageCollect({}); expect( await bucket.readGzippedJson('latest/api/packages/retry'), @@ -216,7 +216,7 @@ void main() { ); // Check that GC after 2 days won't delete a package we know - fakeTime.elapseSync(days: 2); + clockControl.elapseSync(days: 2); await exportedApi.garbageCollect({'retry'}); expect( await bucket.readGzippedJson('latest/api/packages/retry'), @@ -256,8 +256,7 @@ void main() { } }); - testWithFakeTime('ExportedApi.package().synchronizeTarballs()', - (fakeTime) async { + testWithProfile('ExportedApi.package().synchronizeTarballs()', fn: () async { await storageService.createBucket('exported-api'); final bucket = storageService.bucket('exported-api'); final exportedApi = ExportedApi(storageService, bucket); @@ -313,7 +312,7 @@ void main() { [3, 0, 0], ); - fakeTime.elapseSync(days: 2); + clockControl.elapseSync(days: 2); await exportedApi.package('retry').synchronizeTarballs({ '1.0.0': src1, @@ -338,7 +337,7 @@ void main() { ); }); - testWithFakeTime('ExportedApi.package().garbageCollect()', (fakeTime) async { + testWithProfile('ExportedApi.package().garbageCollect()', fn: () async { await storageService.createBucket('exported-api'); final bucket = storageService.bucket('exported-api'); final exportedApi = ExportedApi(storageService, bucket); @@ -363,7 +362,7 @@ void main() { ); // Nothing is GC'ed after 10 mins - fakeTime.elapseSync(minutes: 10); + clockControl.elapseSync(minutes: 10); await exportedApi.package('retry').garbageCollect({'1.2.3'}); expect( await bucket.readBytes('latest/api/archives/retry-1.2.3.tar.gz'), @@ -375,7 +374,7 @@ void main() { ); // Something is GC'ed after 2 days - fakeTime.elapseSync(days: 2); + clockControl.elapseSync(days: 2); await exportedApi.package('retry').garbageCollect({'1.2.3'}); expect( await bucket.readBytes('latest/api/archives/retry-1.2.3.tar.gz'), diff --git a/app/test/shared/test_services.dart b/app/test/shared/test_services.dart index 5467ffe847..9cee47256f 100644 --- a/app/test/shared/test_services.dart +++ b/app/test/shared/test_services.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; + import 'package:clock/clock.dart'; import 'package:fake_gcloud/mem_datastore.dart'; import 'package:fake_gcloud/mem_storage.dart'; @@ -27,6 +28,7 @@ import 'package:pub_dev/shared/integrity.dart'; import 'package:pub_dev/shared/logging.dart'; import 'package:pub_dev/shared/redis_cache.dart'; import 'package:pub_dev/shared/versions.dart'; +import 'package:pub_dev/task/clock_control.dart'; import 'package:pub_dev/task/cloudcompute/fakecloudcompute.dart'; import 'package:pub_dev/task/global_lock.dart'; import 'package:pub_dev/tool/neat_task/pub_dev_tasks.dart'; @@ -37,12 +39,19 @@ import 'package:pub_dev/tool/utils/pub_api_client.dart'; import 'package:test/test.dart'; import '../shared/utils.dart'; -import '../task/fake_time.dart'; import 'handlers_test_utils.dart'; import 'test_models.dart'; export 'package:pub_dev/tool/utils/pub_api_client.dart'; +void _registerClockControl(ClockController clockControl) => + register(#_clockControl, clockControl); + +/// Get [ClockController] for manipulating time. +/// +/// This is only valid inside [testWithProfile]. +ClockController get clockControl => lookup(#_clockControl) as ClockController; + /// Create a [StaticFileCache] for reuse when testing. // TODO: Find out if there are any downsides to doing this, and make forTests() // a factory constructor that always returns the same value. @@ -64,7 +73,7 @@ Future withRuntimeVersions( /// The stored and shared state of the appengine context (including datastore, /// storage and cloud compute). -class FakeAppengineEnv { +final class FakeAppengineEnv { final _storage = MemStorage(); final _datastore = MemDatastore(); final _cloudCompute = FakeCloudCompute(); @@ -87,36 +96,39 @@ class FakeAppengineEnv { if (runtimeVersions != null) { registerAcceptedRuntimeVersions(runtimeVersions); } - return await withFakeServices( - datastore: _datastore, - storage: _storage, - cloudCompute: _cloudCompute, - fn: () async { - registerStaticFileCacheForTest(_staticFileCacheForTesting); + return await withClockControl((clockControl) async { + _registerClockControl(clockControl); + return await withFakeServices( + datastore: _datastore, + storage: _storage, + cloudCompute: _cloudCompute, + fn: () async { + registerStaticFileCacheForTest(_staticFileCacheForTesting); - if (testProfile != null) { - await importProfile( - profile: testProfile, - source: importSource, - ); - } - if (processJobsWithFakeRunners) { - await generateFakeDownloadCountsInDatastore(); - await processTasksWithFakePanaAndDartdoc(); - } - await nameTracker.reloadFromDatastore(); - await indexUpdater.updateAllPackages(); - await topPackages.start(); - await youtubeBackend.start(); - await asyncQueue.ongoingProcessing; - fakeEmailSender.sentMessages.clear(); + if (testProfile != null) { + await importProfile( + profile: testProfile, + source: importSource, + ); + } + if (processJobsWithFakeRunners) { + await generateFakeDownloadCountsInDatastore(); + await processTasksWithFakePanaAndDartdoc(); + } + await nameTracker.reloadFromDatastore(); + await indexUpdater.updateAllPackages(); + await topPackages.start(); + await youtubeBackend.start(); + await asyncQueue.ongoingProcessing; + fakeEmailSender.sentMessages.clear(); - await fork(() async { - await fn(); - }); - await _postTestVerification(integrityProblem: integrityProblem); - }, - ); + await fork(() async { + await fn(); + }); + await _postTestVerification(integrityProblem: integrityProblem); + }, + ); + }); }) as R; } } @@ -180,43 +192,6 @@ void testWithProfile( ); } -/// Execute [fn] with [FakeTime.run] inside [testWithProfile]. -@isTest -void testWithFakeTime( - String name, - FutureOr Function(FakeTime fakeTime) fn, { - TestProfile? testProfile, - ImportSource? importSource, - Pattern? integrityProblem, - Iterable? expectedLogMessages, -}) { - scopedTest(name, expectedLogMessages: expectedLogMessages, () async { - await FakeTime.run((fakeTime) async { - setupDebugEnvBasedLogging(); - await withFakeServices( - fn: () async { - registerStaticFileCacheForTest(_staticFileCacheForTesting); - - await importProfile( - profile: testProfile ?? defaultTestProfile, - source: importSource, - ); - await nameTracker.reloadFromDatastore(); - await generateFakeDownloadCountsInDatastore(); - await indexUpdater.updateAllPackages(); - await asyncQueue.ongoingProcessing; - fakeEmailSender.sentMessages.clear(); - - await fork(() async { - await fn(fakeTime); - }); - await _postTestVerification(integrityProblem: integrityProblem); - }, - ); - }); - }); -} - void setupTestsWithCallerAuthorizationIssues( Future Function(PubApiClient client) fn) { testWithProfile('No active user', fn: () async { diff --git a/app/test/shared/timer_import_test.dart b/app/test/shared/timer_import_test.dart index 704a0866a6..8bfda78d1f 100644 --- a/app/test/shared/timer_import_test.dart +++ b/app/test/shared/timer_import_test.dart @@ -49,6 +49,9 @@ void main() { // Uses timer to track event loop latencies. 'lib/tool/utils/event_loop_tracker.dart', + + // Uses timer to implement delays + 'lib/task/clock_control.dart', }; test('Timer is used only with a permitted pattern', () { diff --git a/app/test/task/fake_time_test.dart b/app/test/task/fake_time_test.dart deleted file mode 100644 index 857ed5c711..0000000000 --- a/app/test/task/fake_time_test.dart +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) 2022, 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. - -import 'dart:async'; - -import 'package:test/test.dart'; -import 'fake_time.dart'; - -void main() { - test('FakeTime.run() with sync callback', () async { - var ok = false; - await FakeTime.run((tm) async { - ok = true; - }); - expect(ok, isTrue); - }); - - test('FakeTime.run() with async microtasks', () async { - var ok1 = false; - var ok2 = false; - await FakeTime.run((tm) async { - await Future.microtask(() { - ok1 = true; - }); - ok2 = true; - }); - expect(ok1, isTrue); - expect(ok2, isTrue); - }); - - test('nested microtasks', () async { - var ok1 = false; - var ok2 = false; - await FakeTime.run((tm) async { - await Future.microtask(() async { - await Future.microtask(() { - ok1 = true; - }); - }); - ok2 = true; - }); - expect(ok1, isTrue); - expect(ok2, isTrue); - }); - - test('create a Timer with zero delay', () async { - var ok = false; - await FakeTime.run((tm) async { - await Future.delayed(Duration.zero); - ok = true; - }); - expect(ok, isTrue); - }); - - test('create a Timer with 1 ms delay', () async { - var ok = false; - await FakeTime.run((tm) async { - await Future.delayed(Duration(milliseconds: 1)); - ok = true; - }); - expect(ok, isTrue); - }); - - test('elapse(zero) runs microtasks', () async { - var ok = false; - await FakeTime.run((tm) async { - scheduleMicrotask(() { - ok = true; - }); - expect(ok, isFalse); - await tm.elapse(); - expect(ok, isTrue); - }); - expect(ok, isTrue); - }); - - test('elapseSync(zero) does not run microtasks', () async { - var ok = false; - await FakeTime.run((tm) async { - scheduleMicrotask(() { - ok = true; - }); - expect(ok, isFalse); - tm.elapseSync(); - expect(ok, isFalse); - }); - }); - - test('Timer.isActive becomes false after delay', () async { - var ok = false; - await FakeTime.run((tm) async { - final timer = Timer(Duration(milliseconds: 1), () { - ok = true; - }); - expect(timer.isActive, isTrue); - await Future.delayed(Duration(milliseconds: 5)); - expect(timer.isActive, isFalse); - expect(ok, isTrue); - }); - }); - - test('Timer.isActive becomes false after elapse(..)', () async { - var ok = false; - await FakeTime.run((tm) async { - final timer = Timer(Duration(milliseconds: 1), () { - ok = true; - }); - expect(timer.isActive, isTrue); - await tm.elapse(milliseconds: 10); - expect(timer.isActive, isFalse); - expect(ok, isTrue); - }); - }); - - test('Timer can be cancelled', () async { - var ok = false; - await FakeTime.run((tm) async { - final timer = Timer(Duration(milliseconds: 1), () { - ok = true; - }); - expect(timer.isActive, isTrue); - timer.cancel(); - expect(timer.isActive, isFalse); - await tm.elapse(milliseconds: 10); - expect(timer.isActive, isFalse); - expect(ok, isFalse); - }); - }); - - test('Timer can be cancelled after elapse(1)', () async { - var ok = false; - await FakeTime.run((tm) async { - final timer = Timer(Duration(milliseconds: 100), () { - ok = true; - }); - expect(timer.isActive, isTrue); - await tm.elapse(milliseconds: 1); - timer.cancel(); - expect(timer.isActive, isFalse); - expect(ok, isFalse); - }); - }); - - test('Timer can be cancelled after delay of 1 ms', () async { - var ok = false; - await FakeTime.run((tm) async { - final timer = Timer(Duration(milliseconds: 100), () { - ok = true; - }); - expect(timer.isActive, isTrue); - await Future.delayed(Duration(milliseconds: 1)); - timer.cancel(); - expect(timer.isActive, isFalse); - expect(ok, isFalse); - }); - }); - - test('Periodic Timer.isActive is called multiple times', () async { - var count = 0; - await FakeTime.run((tm) async { - final timer = Timer.periodic(Duration(milliseconds: 1), (_) { - count += 1; - }); - expect(timer.isActive, isTrue); - expect(timer.tick, 0); - expect(count, 0); - - await Future.delayed(Duration(milliseconds: 5)); - expect(timer.isActive, isTrue); - expect(count, greaterThan(0)); - expect(timer.tick, greaterThan(0)); - - await Future.delayed(Duration(milliseconds: 5)); - expect(count, lessThan(50)); - expect(count, greaterThan(2)); - expect(timer.tick, lessThan(50)); - expect(timer.tick, greaterThan(2)); - - final currentCount = count; - timer.cancel(); - await Future.delayed(Duration(milliseconds: 5)); - expect(count, currentCount); - expect(timer.isActive, isFalse); - }); - }); -} diff --git a/app/test/task/task_test.dart b/app/test/task/task_test.dart index 19aedd253d..bb0e60ce8d 100644 --- a/app/test/task/task_test.dart +++ b/app/test/task/task_test.dart @@ -22,22 +22,6 @@ import 'package:test/test.dart'; import '../shared/handlers_test_utils.dart'; import '../shared/test_services.dart'; -import 'fake_time.dart'; - -Future delay({ - int days = 0, - int hours = 0, - int minutes = 0, - int seconds = 0, - int milliseconds = 0, -}) => - Future.delayed(Duration( - days: days, - hours: hours, - minutes: minutes, - seconds: seconds, - milliseconds: milliseconds, - )); extension on FakeCloudInstance { /// First argument is always a JSON blob with the [Payload]. @@ -49,7 +33,7 @@ extension on FakeCloudInstance { FakeCloudCompute get cloud => taskWorkerCloudCompute as FakeCloudCompute; void main() { - testWithFakeTime('tasks can scheduled and processed', + testWithProfile('tasks can scheduled and processed', testProfile: TestProfile( defaultUser: 'admin@pub.dev', generatedPackages: [ @@ -68,12 +52,12 @@ void main() { users: [ TestUser(email: 'admin@pub.dev', likes: []), ], - ), (fakeTime) async { + ), fn: () async { await taskBackend.backfillTrackingState(); - await fakeTime.elapse(minutes: 1); + await clockControl.elapse(minutes: 1); await taskBackend.start(); - await fakeTime.elapse(minutes: 5); + await clockControl.elapse(minutes: 5); // Check that the log is missing. final log1 = await taskBackend.taskLog('oxygen', '1.2.0'); @@ -95,7 +79,7 @@ void main() { cloud.fakeStartInstance(instance.instanceName); } - await fakeTime.elapse(minutes: 5); + await clockControl.elapse(minutes: 5); for (final instance in instances) { final payload = instance.payload; @@ -162,7 +146,7 @@ void main() { } } - await fakeTime.elapse(minutes: 5); + await clockControl.elapse(minutes: 5); // Check that we can get the log file final log2 = await taskBackend.taskLog('oxygen', '1.2.0'); @@ -187,12 +171,12 @@ void main() { await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }); - testWithFakeTime('failing instances will be retried', (fakeTime) async { + testWithProfile('failing instances will be retried', fn: () async { await taskBackend.backfillTrackingState(); - await fakeTime.elapse(minutes: 1); + await clockControl.elapse(minutes: 1); await taskBackend.start(); @@ -200,7 +184,7 @@ void main() { // try to scheduled it until we hit the [taskRetryLimit]. for (var i = 0; i < taskRetryLimit; i++) { // Within 24 hours an instance should be created - await fakeTime.elapseUntil( + await clockControl.elapseUntil( () => cloud.listInstances().isNotEmpty, timeout: Duration(days: 1), ); @@ -208,7 +192,7 @@ void main() { // If nothing happens, then it should be killed within 24 hours. // Actually, it'll happen much sooner, like ~2 hours, but we'll leave the // test some wiggle room. - await fakeTime.elapseUntil( + await clockControl.elapseUntil( () => cloud.listInstances().isEmpty, timeout: Duration(days: 1), ); @@ -218,7 +202,7 @@ void main() { // created for the next day... assert(taskRetriggerInterval > Duration(days: 1)); await expectLater( - fakeTime.elapseUntil( + clockControl.elapseUntil( () => cloud.listInstances().isNotEmpty, timeout: Duration(days: 1), ), @@ -227,14 +211,14 @@ void main() { // But the task should be retried after [taskRetriggerInterval], this is a // long time, but for sanity we do re-analyze everything occasionally. - await fakeTime.elapseUntil( + await clockControl.elapseUntil( () => cloud.listInstances().isNotEmpty, timeout: taskRetriggerInterval + Duration(days: 1), ); await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -250,11 +234,11 @@ void main() { ], )); - testWithFakeTime('Limit to 5 latest major versions', (fakeTime) async { + testWithProfile('Limit to 5 latest major versions', fn: () async { await taskBackend.backfillTrackingState(); - await fakeTime.elapse(minutes: 1); + await clockControl.elapse(minutes: 1); await taskBackend.start(); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); // We expect there to be one instance with less than 10 versions to be // analyzed, this even though there really is 20 versions. @@ -277,7 +261,7 @@ void main() { await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -304,10 +288,10 @@ void main() { ], )); - testWithFakeTime('continued scan finds new packages', (fakeTime) async { + testWithProfile('continued scan finds new packages', fn: () async { await taskBackend.backfillTrackingState(); await taskBackend.start(); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); expect(await cloud.listInstances().toList(), hasLength(0)); @@ -325,13 +309,13 @@ void main() { ), ); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); expect(await cloud.listInstances().toList(), hasLength(1)); await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -341,10 +325,10 @@ void main() { ], )); - testWithFakeTime('analyzed packages stay idle', (fakeTime) async { + testWithProfile('analyzed packages stay idle', fn: () async { await taskBackend.backfillTrackingState(); await taskBackend.start(); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); final instances = await cloud.listInstances().toList(); // There is only one package, so we should only get one instance @@ -402,17 +386,17 @@ void main() { await api.taskUploadFinished(payload.package, v.version); // Leave time for the instance to be deleted (takes 1 min in fake cloud) - await fakeTime.elapse(minutes: 5); + await clockControl.elapse(minutes: 5); // We don't expect anything to be scheduled for the next 7 days. - await fakeTime.expectUntil( + await clockControl.expectUntil( () => cloud.listInstances().isEmpty, Duration(days: 7), ); await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -428,10 +412,10 @@ void main() { ], )); - testWithFakeTime('continued scan finds new versions', (fakeTime) async { + testWithProfile('continued scan finds new versions', fn: () async { await taskBackend.backfillTrackingState(); await taskBackend.start(); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); { final instances = await cloud.listInstances().toList(); // There is only one package, so we should only get one instance @@ -489,10 +473,10 @@ void main() { await api.taskUploadFinished(payload.package, v.version); } // Leave time for the instance to be deleted (takes 1 min in fake cloud) - await fakeTime.elapse(minutes: 5); + await clockControl.elapse(minutes: 5); // We don't expect anything to be scheduled for the next 3 days. - await fakeTime.expectUntil( + await clockControl.expectUntil( () => cloud.listInstances().isEmpty, Duration(days: 3), ); @@ -511,7 +495,7 @@ void main() { ), ); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); { final instances = await cloud.listInstances().toList(); @@ -535,7 +519,7 @@ void main() { await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -551,10 +535,10 @@ void main() { ], )); - testWithFakeTime('re-analyzes when dependency is updated', (fakeTime) async { + testWithProfile('re-analyzes when dependency is updated', fn: () async { await taskBackend.backfillTrackingState(); await taskBackend.start(); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); // There should be 2 packages for analysis now expect(await cloud.listInstances().toList(), hasLength(2)); @@ -627,7 +611,7 @@ void main() { } // Leave time for the instance to be deleted (takes 1 min in fake cloud) - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); // We don't expect anything to be scheduled now expect(await cloud.listInstances().toList(), isEmpty); @@ -647,7 +631,7 @@ void main() { ), ); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); // Expect that neon is scheduled within 15 minutes expect( @@ -657,7 +641,7 @@ void main() { // Since oxygen was recently scheduled, we expect that it won't have been // scheduled yet. - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); expect( await cloud.listInstances().map((i) => i.payload.package).toList(), isNot(contains('oxygen')), @@ -665,14 +649,14 @@ void main() { // At some point oxygen must also be retriggered, by this can be offset by // the [taskDependencyRetriggerCoolOff] delay. - await fakeTime.elapseUntil( + await clockControl.elapseUntil( () => cloud.listInstances().any((i) => i.payload.package == 'oxygen'), timeout: taskDependencyRetriggerCoolOff + Duration(minutes: 15), ); await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -693,7 +677,7 @@ void main() { ], )); - testWithFakeTime( + testWithProfile( 'new version during ongoing analysis', testProfile: TestProfile( defaultUser: 'admin@pub.dev', @@ -707,10 +691,10 @@ void main() { TestUser(email: 'admin@pub.dev', likes: []), ], ), - (fakeTime) async { + fn: () async { await taskBackend.backfillTrackingState(); await taskBackend.start(); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); { final instances = await cloud.listInstances().toList(); // There is only one package, so we should only get one instance @@ -743,7 +727,7 @@ void main() { ), ); - await fakeTime.elapse(minutes: 15); + await clockControl.elapse(minutes: 15); // Use token to get the upload information final api = createPubApiClient(authToken: v.token); @@ -766,11 +750,11 @@ void main() { ); } // Leave time for the instance to be deleted (takes 1 min in fake cloud) - await fakeTime.elapse(minutes: 5); + await clockControl.elapse(minutes: 5); await taskBackend.stop(); - await fakeTime.elapse(minutes: 10); + await clockControl.elapse(minutes: 10); }, ); } @@ -781,21 +765,6 @@ extension on Stream { } } -extension on FakeTime { - /// Expect [condition] to return `true` until [duration] has elapsed. - Future expectUntil( - FutureOr Function() condition, Duration duration) async { - try { - await elapseUntil(() async { - return !await condition(); - }, timeout: duration); - fail('Condition failed before $duration expired'); - } on TimeoutException { - return; - } - } -} - Future upload( http.Client client, UploadInfo destination,