taproom/test/brew_client_test.dart
Philip Peterson 24dcfcf70d Taproom: a Flutter desktop frontend for Homebrew
Browse installed packages, upgrade outdated ones, search formulae and
casks, and explore taps, all by driving the brew CLI. Includes a
Taskfile for running, testing and building.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:12:21 -08:00

177 lines
5.6 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:taproom/brew/brew_client.dart';
import 'package:taproom/brew/models.dart';
/// Records calls and answers each with a canned result chosen by [respond].
class FakeRunner {
FakeRunner(this.respond);
final ProcessResult Function(List<String> args) respond;
final calls = <(List<String>, Map<String, String>)>[];
Future<ProcessResult> call(
String exe,
List<String> args,
Map<String, String> env,
) async {
calls.add((args, env));
return respond(args);
}
}
ProcessResult result(String stdout, {int exitCode = 0, String stderr = ''}) =>
ProcessResult(0, exitCode, stdout, stderr);
void main() {
test('search queries formulae and casks and tolerates no matches', () async {
final runner = FakeRunner(
(args) => args.contains('--formula')
? result('jq\njq-lsp\n')
// brew exits 1 with a message when nothing matches.
: result('', exitCode: 1, stderr: 'Error: No casks found for "jq".'),
);
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
runner: runner.call,
);
final results = await brew.search('jq');
expect(results.map((r) => (r.name, r.kind)), [
('jq', PackageKind.formula),
('jq-lsp', PackageKind.formula),
]);
expect(runner.calls.map((c) => c.$1), [
['search', '--formula', 'jq'],
['search', '--cask', 'jq'],
]);
});
test('search output parsing skips headers and prose', () {
final r = BrewClient.parseSearchOutput(
'==> Formulae\nfoo\n\nIf you meant "foo" specifically:\nbar\n',
PackageKind.formula,
);
expect(r.map((e) => e.name), ['foo', 'bar']);
});
test('queries run with a usable PATH and without auto-update', () async {
final runner = FakeRunner((_) => result('{"formulae": [], "casks": []}'));
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
runner: runner.call,
);
await brew.installed();
final env = runner.calls.single.$2;
expect(env['PATH'], startsWith('/opt/homebrew/bin:/usr/bin:'));
expect(env['HOMEBREW_NO_AUTO_UPDATE'], '1');
expect(env['HOMEBREW_NO_COLOR'], '1');
});
test('a failing query throws BrewException with stderr', () async {
final runner = FakeRunner(
(_) => result('', exitCode: 1, stderr: 'Error: No available formula'),
);
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
runner: runner.call,
);
expect(
() => brew.info('nope', PackageKind.formula),
throwsA(
isA<BrewException>().having(
(e) => e.stderr,
'stderr',
contains('No available formula'),
),
),
);
});
test(
'BrewTask streams stdout and stderr and records the exit code',
() async {
late Map<String, String> seenEnv;
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
askpassPath: '/tmp/askpass',
starter: (exe, args, env) {
seenEnv = env;
return Process.start('/bin/sh', [
'-c',
'echo installing; echo warning >&2; exit 3',
]);
},
);
final task = brew.install('jq', PackageKind.formula);
var notified = 0;
task.addListener(() => notified++);
final code = await task.run();
expect(code, 3);
expect(task.succeeded, isFalse);
expect(task.running, isFalse);
expect(task.lines.first, r'$ brew install --formula jq');
expect(task.lines, containsAll(['installing', 'warning']));
expect(notified, greaterThan(2));
// Mutations may auto-update and may need sudo via the askpass helper.
expect(seenEnv.containsKey('HOMEBREW_NO_AUTO_UPDATE'), isFalse);
expect(seenEnv['SUDO_ASKPASS'], '/tmp/askpass');
},
);
test('upgrade builds the right arguments', () {
final brew = BrewClient(brewPath: '/b/brew');
expect(brew.upgrade().args, ['upgrade']);
expect(brew.upgrade(greedy: true).args, ['upgrade', '--greedy']);
expect(brew.upgrade(name: 'firefox', kind: PackageKind.cask).args, [
'upgrade',
'--cask',
'firefox',
]);
expect(brew.addTap('a/b', url: '').args, ['tap', 'a/b']);
expect(brew.addTap('a/b', url: 'https://x/y.git').args, [
'tap',
'a/b',
'https://x/y.git',
]);
});
// Read-only checks against the real Homebrew on this machine, to catch
// drift between brew's JSON and the parsers.
group('real brew', () {
final path = BrewClient.locate();
final brew = path == null ? null : BrewClient(brewPath: path);
test('lists installed packages', () async {
final installed = await brew!.installed();
expect(installed, everyElement(isA<Package>()));
expect(installed.where((p) => !p.isInstalled), isEmpty);
}, skip: brew == null ? 'Homebrew not installed' : false);
test('lists taps including homebrew/core', () async {
final taps = await brew!.taps();
expect(taps.map((t) => t.name), contains('homebrew/core'));
final core = taps.firstWhere((t) => t.name == 'homebrew/core');
expect(core.formulaNames, contains('jq'));
}, skip: brew == null ? 'Homebrew not installed' : false);
test('searches', () async {
final r = await brew!.search('jq');
expect(r.map((e) => e.name), contains('jq'));
}, skip: brew == null ? 'Homebrew not installed' : false);
test('gets cask info', () async {
final p = await brew!.info('firefox', PackageKind.cask);
expect(p?.displayName, isNotEmpty);
expect(jsonEncode(p?.name), '"firefox"');
}, skip: brew == null ? 'Homebrew not installed' : false);
});
}