Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkgs/checks/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

- Require Dart 3.7
- Improve speed of pretty printing for large collections.
- Add `package:checks/io.dart` with expectations that a synchronous or
asynchronous callback calls `exit()`.

## 0.3.1

Expand Down
5 changes: 5 additions & 0 deletions pkgs/checks/lib/io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) 2026, 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.

export 'src/extensions/io.dart' show IoAsyncFunctionChecks, IoFunctionChecks;
111 changes: 111 additions & 0 deletions pkgs/checks/lib/src/extensions/io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026, 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:convert';
import 'dart:io';

import '../../context.dart';

extension IoFunctionChecks<T> on Subject<T Function()> {
/// Expects that a function calls [exit] synchronously when it is called.
///
/// If the function synchronously calls [exit], return a [Subject] to check
/// further expectations on the exit code.
///
/// If the function does not call [exit] synchronously, or if it throws an
/// error, this expectation will fail.
///
/// WARNING: This check relies on throwing an [Error] internally to detect
/// the call to [exit]. If the code under test has a catch-all block that
/// catches [Error] (e.g. `catch (e)` where `e` is not restricted to
/// [Exception]), it may intercept this error and prevent the check from
/// working correctly.
Subject<int> exits() {
return context.nest<int>(() => ['exits the process'], (actual) {
try {
final result = IOOverrides.runWithIOOverrides(
actual,
_ExitIOOverrides(),
);
return Extracted.rejection(
actual: prefixFirst('a function that returned ', literal(result)),
which: ['did not exit'],
);
} on _ExitError catch (e) {
return Extracted.value(e.code);
} catch (e, st) {
return Extracted.rejection(
actual: prefixFirst('a function that threw error ', literal(e)),
which: [
'threw an exception at:',
...indent(const LineSplitter().convert(st.toString())),
],
);
}
});
}
}

extension IoAsyncFunctionChecks<T> on Subject<Future<T> Function()> {
/// Expects that the future returned by the function calls [exit]
/// asynchronously.
///
/// If the future calls [exit], check further expectations on the exit code
/// with [exitCodeCondition].
///
/// If the future completes normally or completes to an error, this
/// expectation will fail.
///
/// WARNING: This check relies on throwing an [Error] internally to detect
/// the call to [exit]. If the code under test has a catch-all block that
/// catches [Error] (e.g. `catch (e)` where `e` is not restricted to
/// [Exception]), it may intercept this error and prevent the check from
/// working correctly.
Future<void> exits([AsyncCondition<int>? exitCodeCondition]) async {
await context.nestAsync<int>(() => ['exits the process'], (actual) async {
try {
final result = await IOOverrides.runWithIOOverrides(
actual,
_ExitIOOverrides(),
);
return Extracted.rejection(
actual: prefixFirst('completed to ', literal(result)),
which: ['did not exit'],
);
} on _ExitError catch (e) {
return Extracted.value(e.code);
} catch (e, st) {
return Extracted.rejection(
actual: prefixFirst('completed to error ', literal(e)),
which: [
'threw an exception at:',
...indent(const LineSplitter().convert(st.toString())),
],
);
}
}, exitCodeCondition);
}
}

/// An [Error] thrown by [_ExitIOOverrides] to detect calls to [exit].
///
/// We use [Error] instead of [Exception] because catch-all exception handlers
/// (e.g. `on Exception catch (e)`) are more common than catch-all error
/// handlers, and we want to avoid our exit signal being caught by the code
/// under test.
///
/// However, catch-all handlers that catch everything (e.g. `catch (e)`) will
/// still catch this error.
final class _ExitError extends Error {
final int code;
_ExitError(this.code);
}

final class _ExitIOOverrides extends IOOverrides {
@override
Never exit(int code) {
throw _ExitError(code);
}
}
109 changes: 109 additions & 0 deletions pkgs/checks/test/extensions/io_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026, 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:io';

import 'package:checks/checks.dart';
import 'package:checks/io.dart';
import 'package:test/scaffolding.dart';

import '../test_shared.dart';

void main() {
group('IoFunctionChecks', () {
group('exits', () {
test('succeeds for happy case', () {
check(() => exit(42)).exits().equals(42);
});
test('fails for functions that return normally', () {
check(() {}).isRejectedBy(
(it) => it.exits(),
actual: ['a function that returned <null>'],
which: ['did not exit'],
);
});
test('fails for functions that throw', () {
check(() {
Error.throwWithStackTrace(
StateError('oops!'),
StackTrace.fromString('fake trace'),
);
}).isRejectedBy(
(it) => it.exits(),
actual: ['a function that threw error <Bad state: oops!>'],
which: ['threw an exception at:', ' fake trace'],
);
});
test('succeeds even if function catches Exception', () {
check(() {
try {
exit(42);
} on Exception catch (_) {
// should not catch _ExitError
}
}).exits().equals(42);
});
test('fails if function catches Error', () {
check(() {
try {
exit(42);
} on Error catch (_) {
// swallows _ExitError
}
}).isRejectedBy(
(it) => it.exits(),
actual: ['a function that returned <null>'],
which: ['did not exit'],
);
});
});
});

group('IoAsyncFunctionChecks', () {
group('exits', () {
test('succeeds for happy case', () async {
await check(() async => exit(42)).exits((it) => it.equals(42));
});
test('succeeds for synchronous exit', () async {
Future<void> syncExit() {
exit(42);
}

await check(syncExit).exits((it) => it.equals(42));
});
test('fails for futures that complete normally', () async {
await check(() async => 1).isRejectedByAsync(
(it) => it.exits(),
actual: ['completed to <1>'],
which: ['did not exit'],
);
});
test('fails for futures that complete to an error', () async {
await check(_futureFail).isRejectedByAsync(
(it) => it.exits(),
actual: ['completed to error <UnimplementedError>'],
which: ['threw an exception at:', ' fake trace'],
);
});
test('can be described', () async {
await check(
(Subject<Future<void> Function()> it) => it.exits(),
).hasAsyncDescriptionWhich(
(it) => it.deepEquals([' exits the process']),
);
await check(
(Subject<Future<void> Function()> it) =>
it.exits((it) => it.equals(42)),
).hasAsyncDescriptionWhich(
(it) =>
it.deepEquals([' exits the process that:', ' equals <42>']),
);
});
});
});
}

Future<int> _futureFail() =>
Future.error(UnimplementedError(), StackTrace.fromString('fake trace'));
Loading