chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)

Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:11:53 +02:00
co-authored by Claude Opus 4.8
parent bcea5f15b7
commit 6d0ebab721
444 changed files with 7587 additions and 12849 deletions
+6 -33
View File
@@ -16,29 +16,10 @@ void main() {
/// (namespace, key) pairs referenced by Tier-0 built-ins. Extend when
/// new keys land.
const referenced = <String, List<String>>{
'builtin.welcome': [
'title',
'subtitle',
'open-project',
'open-project.hint',
'tab.title',
],
'builtin.ipc-status': [
'connected',
'connected.hint',
'disconnected',
'disconnected.hint',
],
'builtin.theme-picker': [
'modal.title',
'modal.cancel',
'modal.cancel.hint',
'row.select.hint',
],
'builtin.default-layout': [
'command.reset',
'preset.classic',
],
'builtin.welcome': ['title', 'subtitle', 'open-project', 'open-project.hint', 'tab.title'],
'builtin.ipc-status': ['connected', 'connected.hint', 'disconnected', 'disconnected.hint'],
'builtin.theme-picker': ['modal.title', 'modal.cancel', 'modal.cancel.hint', 'row.select.hint'],
'builtin.default-layout': ['command.reset', 'preset.classic'],
};
group('i18n coverage (Tier 0)', () {
@@ -47,17 +28,9 @@ void main() {
test('$ns catalog contains every referenced key', () async {
final loader = AssetCatalogLoader(bundle: rootBundle);
final catalog = await loader.load(ns, const Locale('en', 'US'));
expect(
catalog,
isNotEmpty,
reason: 'catalog for "$ns" failed to load (asset path wrong?)',
);
expect(catalog, isNotEmpty, reason: 'catalog for "$ns" failed to load (asset path wrong?)');
for (final key in entry.value) {
expect(
catalog.containsKey(key),
isTrue,
reason: 'namespace "$ns" catalog is missing key "$key"',
);
expect(catalog.containsKey(key), isTrue, reason: 'namespace "$ns" catalog is missing key "$key"');
}
});
}
+1 -3
View File
@@ -34,9 +34,7 @@ void main() {
});
testWidgets('interactive widgets expose tap actions to a11y', (tester) async {
await tester.pumpWidget(
harness(f, ClideButton(label: 'Save', onPressed: () {})),
);
await tester.pumpWidget(harness(f, ClideButton(label: 'Save', onPressed: () {})));
final handle = tester.ensureSemantics();
final data = tester.getSemantics(find.byType(ClideButton)).getSemanticsData();
expect(data.hasAction(SemanticsAction.tap), isTrue);
+1 -6
View File
@@ -17,12 +17,7 @@ import 'package:flutter_test/flutter_test.dart';
/// catalog" gate.
void main() {
group('Tier-0 built-in extensions — contract-level coverage', () {
final extensions = <ClideExtension>[
DefaultLayoutExtension(),
WelcomeExtension(),
IpcStatusExtension(),
ThemePickerExtension(),
];
final extensions = <ClideExtension>[DefaultLayoutExtension(), WelcomeExtension(), IpcStatusExtension(), ThemePickerExtension()];
for (final ext in extensions) {
group(ext.id, () {
+31 -22
View File
@@ -16,18 +16,21 @@ import 'helpers/kernel_fixture.dart';
Finder _icon(PhosphorIconPainter p) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter == p);
Widget _host(KernelFixture f, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(size: Size(800, 200)),
child: Align(alignment: Alignment.topLeft, child: SizedBox(height: 26, child: child)),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(size: Size(800, 200)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(height: 26, child: child),
),
),
);
),
),
);
void main() {
late KernelFixture f;
@@ -54,13 +57,17 @@ void main() {
testWidgets('the chevron flips live when the collapsed state changes', (tester) async {
var collapsed = false;
late StateSetter setOuter;
await tester.pumpWidget(_host(
f,
StatefulBuilder(builder: (ctx, setState) {
setOuter = setState;
return StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: collapsed, visible: true);
}),
));
await tester.pumpWidget(
_host(
f,
StatefulBuilder(
builder: (ctx, setState) {
setOuter = setState;
return StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: collapsed, visible: true);
},
),
),
);
await tester.pump();
expect(_icon(PhosphorIcons.byName('caret-line-left')), findsOneWidget);
@@ -78,11 +85,13 @@ void main() {
});
testWidgets('tapping fires the matching collapse command', (tester) async {
f.services.arrangement.applyPreset(const LayoutPresetContribution(
id: 'test',
displayName: 'test',
slots: [LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true)],
));
f.services.arrangement.applyPreset(
const LayoutPresetContribution(
id: 'test',
displayName: 'test',
slots: [LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true)],
),
);
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activate('builtin.default-layout');
+16 -29
View File
@@ -16,21 +16,21 @@ import 'package:flutter_test/flutter_test.dart';
import 'helpers/kernel_fixture.dart';
Widget _bar(KernelFixture f, double width) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: MediaQueryData(size: Size(width, 200)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: width, height: 26, child: const StatusbarHost()),
),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: MediaQueryData(size: Size(width, 200)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: width, height: 26, child: const StatusbarHost()),
),
),
);
),
),
);
void main() {
late KernelFixture f;
@@ -53,17 +53,8 @@ void main() {
testWidgets('right group hugs the bar right edge at normal AND ultrawide widths (T-239)', (tester) async {
// Mirrors the real surface: a left flex:1 item (like the Claude status
// marquee, priority 50) + a right-group item (priority >= 100).
f.services.panels.contribute(StatusItemContribution(
id: 'test.left.flex',
priority: 50,
flex: 1,
build: (_) => const Text('LEFT', softWrap: false),
));
f.services.panels.contribute(StatusItemContribution(
id: 'test.right',
priority: 110,
build: (_) => const Text('RIGHT', softWrap: false),
));
f.services.panels.contribute(StatusItemContribution(id: 'test.left.flex', priority: 50, flex: 1, build: (_) => const Text('LEFT', softWrap: false)));
f.services.panels.contribute(StatusItemContribution(id: 'test.right', priority: 110, build: (_) => const Text('RIGHT', softWrap: false)));
// 600 = normal, 3440 = ultrawide (where the float actually surfaced).
for (final width in [600.0, 3440.0]) {
@@ -77,11 +68,7 @@ void main() {
});
testWidgets('right group hugs the edge with no left items (ultrawide)', (tester) async {
f.services.panels.contribute(StatusItemContribution(
id: 'test.right.only',
priority: 110,
build: (_) => const Text('R2', softWrap: false),
));
f.services.panels.contribute(StatusItemContribution(id: 'test.right.only', priority: 110, build: (_) => const Text('R2', softWrap: false)));
await pumpAt(tester, 3440.0);
expect(tester.takeException(), isNull);
expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 8, 1.0));
+4 -7
View File
@@ -283,13 +283,10 @@ void main() {
});
testWidgets('switcher → Open Local Project falls back to the path dialog (no native picker)', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('clide/window'),
(call) async {
if (call.method == 'pickDirectory') throw MissingPluginException();
return null;
},
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async {
if (call.method == 'pickDirectory') throw MissingPluginException();
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), null));
final repo = Directory.current.path;
@@ -57,12 +57,7 @@ void main() {
});
test('a failed result surfaces (sticky) and breaks the cluster', () {
final groups = groupConversation([
_tool('1', 'Bash'),
_result('1', isError: true),
_tool('2', 'Bash'),
_result('2'),
], FoldLevel.tools);
final groups = groupConversation([_tool('1', 'Bash'), _result('1', isError: true), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
// [Folded([tool1]), Sticky(errorResult), Folded([tool2,result2])]
expect(groups, hasLength(3));
expect((groups[0] as FoldedCluster).items, hasLength(1));
+5 -29
View File
@@ -37,43 +37,23 @@ void main() {
group('agentEnvDelta (T-215)', () {
test('always exports CLIDE_SOCK + CLIDE_WORKSPACE', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/run/clide/abc.sock',
currentPath: '/usr/bin',
clideCliDir: null,
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/run/clide/abc.sock', currentPath: '/usr/bin', clideCliDir: null);
expect(d['CLIDE_WORKSPACE'], '/repo');
expect(d['CLIDE_SOCK'], '/run/clide/abc.sock');
});
test('leaves PATH untouched when clide is already resolvable (clideCliDir null)', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/usr/bin',
clideCliDir: null,
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin', clideCliDir: null);
expect(d.containsKey('PATH'), isFalse);
});
test('prepends the cli dir to PATH when given', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/usr/bin:/bin',
clideCliDir: '/home/dev/.local/bin',
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: '/home/dev/.local/bin');
expect(d['PATH'], '/home/dev/.local/bin:/usr/bin:/bin');
});
test('sets PATH to just the cli dir when there is no current PATH', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: null,
clideCliDir: '/opt/clide/bin',
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: null, clideCliDir: '/opt/clide/bin');
expect(d['PATH'], '/opt/clide/bin');
});
});
@@ -98,11 +78,7 @@ void main() {
});
test('returns null when nothing holds clide', () {
final dir = resolveClideCliDir(
currentPath: '/usr/bin',
candidateDirs: const ['/a', '/b'],
isExecutableFile: (_) => false,
);
final dir = resolveClideCliDir(currentPath: '/usr/bin', candidateDirs: const ['/a', '/b'], isExecutableFile: (_) => false);
expect(dir, isNull);
});
});
+53 -112
View File
@@ -21,10 +21,7 @@ void main() {
});
test('multi-line input is wrapped in bracketed-paste markers', () {
expect(
encodeClaudeInput('line one\nline two'),
'\x1b[200~line one\nline two\x1b[201~\r',
);
expect(encodeClaudeInput('line one\nline two'), '\x1b[200~line one\nline two\x1b[201~\r');
});
});
@@ -35,10 +32,7 @@ void main() {
Future<List<String>> pump(WidgetTester tester, {bool enabled = true}) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(enabled: enabled, onSubmit: submitted.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(enabled: enabled, onSubmit: submitted.add)));
return submitted;
}
@@ -96,15 +90,17 @@ void main() {
// WidgetsApp provides DefaultTextEditingShortcuts in the real app;
// the bare harness doesn't, so wrap explicitly to map Ctrl+V ->
// PasteTextIntent, which the composer's Actions override intercepts.
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: (_) {},
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: (_) {},
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
),
));
);
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
@@ -113,15 +109,17 @@ void main() {
testWidgets('submit appends attachment @path tokens to the message', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
),
));
);
await tester.enterText(find.byType(EditableText), 'look at this');
await pasteAttachment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
@@ -132,18 +130,9 @@ void main() {
expect(find.text('notes.txt'), findsNothing);
});
Future<List<String>> pumpWithCommands(
WidgetTester tester,
List<String> commands,
) async {
Future<List<String>> pumpWithCommands(WidgetTester tester, List<String> commands) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: submitted.add,
slashCommandsResolver: () => commands,
),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: submitted.add, slashCommandsResolver: () => commands)));
return submitted;
}
@@ -239,15 +228,17 @@ void main() {
testWidgets('remove × cancels the attachment before send', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
),
));
);
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
@@ -263,10 +254,7 @@ void main() {
testWidgets('Escape interrupts when the typeahead is closed', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++)));
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
await tester.pump();
@@ -277,14 +265,7 @@ void main() {
testWidgets('Escape closes the typeahead before it interrupts', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
onInterrupt: () => interrupts++,
slashCommandsResolver: () => ['model'],
),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++, slashCommandsResolver: () => ['model'])));
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
@@ -305,10 +286,7 @@ void main() {
testWidgets('the Stop button shows when busy and interrupts on tap', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++)));
expect(find.text('Stop ⎋'), findsOneWidget);
await tester.tap(find.text('Stop ⎋'));
@@ -317,10 +295,7 @@ void main() {
});
testWidgets('no Stop button when idle', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {}),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {})));
expect(find.text('Stop ⎋'), findsNothing);
});
@@ -353,10 +328,7 @@ void main() {
testWidgets('clide-owned commands are reachable via the default resolver', (tester) async {
// No slashCommandsResolver → default path; kClideOwnedCommands must be included.
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: submitted.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: submitted.add)));
await tester.enterText(find.byType(EditableText), '/fo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
@@ -375,10 +347,7 @@ void main() {
testWidgets('Ctrl+M fires onCycleMode and is consumed', (tester) async {
var cycles = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++)));
await tester.tap(find.byType(EditableText));
await tester.pump();
@@ -394,10 +363,7 @@ void main() {
testWidgets('plain m types normally (no modifier, no cycle)', (tester) async {
var cycles = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++)));
await tester.enterText(find.byType(EditableText), 'm');
await tester.pump();
expect(cycles, 0);
@@ -425,10 +391,7 @@ void main() {
final submitted = <String>[];
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: submitted.add, focusNode: node),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: submitted.add, focusNode: node)));
await tester.enterText(find.byType(EditableText), 'via external node');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
@@ -444,15 +407,8 @@ void main() {
String text(WidgetTester tester) => tester.widget<EditableText>(find.byType(EditableText)).controller.text;
Future<void> pumpWithHistory(
WidgetTester tester, {
required List<String> history,
ValueChanged<TextEditingValue>? onDraftChanged,
}) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, history: history, onDraftChanged: onDraftChanged),
));
Future<void> pumpWithHistory(WidgetTester tester, {required List<String> history, ValueChanged<TextEditingValue>? onDraftChanged}) async {
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, history: history, onDraftChanged: onDraftChanged)));
await tester.tap(find.byType(EditableText));
await tester.pump();
}
@@ -528,16 +484,15 @@ void main() {
tearDown(() => f.dispose());
testWidgets('seeds the field from initialValue on mount', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
initialValue: const TextEditingValue(
text: 'half-typed',
selection: TextSelection.collapsed(offset: 4),
await tester.pumpWidget(
harness(
f,
ClaudeComposer(
onSubmit: (_) {},
initialValue: const TextEditingValue(text: 'half-typed', selection: TextSelection.collapsed(offset: 4)),
),
),
));
);
final controller = tester.widget<EditableText>(find.byType(EditableText)).controller;
expect(controller.text, 'half-typed');
expect(controller.selection.baseOffset, 4); // caret restored too
@@ -545,10 +500,7 @@ void main() {
testWidgets('reports draft changes (text + caret) via onDraftChanged', (tester) async {
final drafts = <TextEditingValue>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add)));
await tester.enterText(find.byType(EditableText), 'draft text');
await tester.pump();
@@ -557,10 +509,7 @@ void main() {
testWidgets('reports an empty draft when submitting clears the field', (tester) async {
final drafts = <TextEditingValue>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add)));
await tester.enterText(find.byType(EditableText), 'send me');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
@@ -589,10 +538,7 @@ void main() {
// Prompt resolved — composer remounts and restores the draft.
showPrompt.value = false;
await tester.pump();
expect(
tester.widget<EditableText>(find.byType(EditableText)).controller.text,
'survived the prompt',
);
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, 'survived the prompt');
});
});
}
@@ -614,13 +560,8 @@ class _DraftSwapHostState extends State<_DraftSwapHost> {
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: widget.showPrompt,
builder: (context, prompt, _) => prompt
? const SizedBox.shrink()
: ClaudeComposer(
onSubmit: (_) {},
initialValue: _draft,
onDraftChanged: (v) => _draft = v,
),
builder: (context, prompt, _) =>
prompt ? const SizedBox.shrink() : ClaudeComposer(onSubmit: (_) {}, initialValue: _draft, onDraftChanged: (v) => _draft = v),
);
}
}
+23 -34
View File
@@ -49,27 +49,10 @@ void main() {
await File('${scope.path}/settings.json').writeAsString(jsonEncode(json));
}
String initLine({
String version = '2.1.150',
List<String> slash = const ['clear', 'pql'],
List<String> skills = const ['pql'],
}) =>
'${jsonEncode({
'type': 'system',
'subtype': 'init',
'claude_code_version': version,
'slash_commands': slash,
'skills': skills,
'model': 'claude-opus-4-7',
'permissionMode': 'default',
})}\n';
String initLine({String version = '2.1.150', List<String> slash = const ['clear', 'pql'], List<String> skills = const ['pql']}) =>
'${jsonEncode({'type': 'system', 'subtype': 'init', 'claude_code_version': version, 'slash_commands': slash, 'skills': skills, 'model': 'claude-opus-4-7', 'permissionMode': 'default'})}\n';
ClaudeConfig build({
ClaudeVersionRunner? versionRunner,
ClaudeInitProbe? initProbe,
ClaudeConfigWatch? watch,
Duration debounce = Duration.zero,
}) =>
ClaudeConfig build({ClaudeVersionRunner? versionRunner, ClaudeInitProbe? initProbe, ClaudeConfigWatch? watch, Duration debounce = Duration.zero}) =>
ClaudeConfig(
globalDir: globalDir,
cacheDir: cacheDir,
@@ -111,14 +94,14 @@ void main() {
'keep': 1,
'permissions': {
'allow': ['Bash'],
'deny': ['Write']
'deny': ['Write'],
},
});
await writeSettings(localDir, {
'model': 'sonnet',
'permissions': {
'allow': ['Edit'],
'ask': ['Read']
'ask': ['Read'],
},
});
@@ -152,10 +135,12 @@ void main() {
test('load stays on the fallback until ensureProbe runs (no eager turn)', () async {
var probeCalls = 0;
final c = build(initProbe: () async {
probeCalls++;
return initLine(slash: ['clear', 'pql', 'whats-next']);
});
final c = build(
initProbe: () async {
probeCalls++;
return initLine(slash: ['clear', 'pql', 'whats-next']);
},
);
await c.load();
expect(probeCalls, 0, reason: 'load must never pay for a model turn');
expect(c.slashCommands, kFallbackSlashCommands);
@@ -192,10 +177,12 @@ void main() {
test('a different claude version misses the cache and re-probes', () async {
var probeCalls = 0;
final c1 = build(initProbe: () async {
probeCalls++;
return initLine();
});
final c1 = build(
initProbe: () async {
probeCalls++;
return initLine();
},
);
await c1.load();
await c1.ensureProbe();
expect(probeCalls, 1);
@@ -281,10 +268,12 @@ void main() {
test('explicit refresh re-reads disk without re-resolving the version', () async {
var versionCalls = 0;
final c = build(versionRunner: () async {
versionCalls++;
return '2.1.150 (Claude Code)\n';
});
final c = build(
versionRunner: () async {
versionCalls++;
return '2.1.150 (Claude Code)\n';
},
);
await c.load();
expect(versionCalls, 1);
+143 -194
View File
@@ -64,14 +64,7 @@ void main() {
ClaudeConfig? config,
SidebarTab initialTab = SidebarTab.activity,
ClaudeSessionOrchestrator? orchestrator,
}) =>
ClaudeMetaSidebar(
statsLoader: () async => stats,
pollInterval: Duration.zero,
config: config,
orchestrator: orchestrator,
initialTab: initialTab,
);
}) => ClaudeMetaSidebar(statsLoader: () async => stats, pollInterval: Duration.zero, config: config, orchestrator: orchestrator, initialTab: initialTab);
testWidgets('Activity tab shows today + lifetime stats on the table', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
@@ -145,15 +138,9 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
model: 'claude-opus-4-7',
color: 'blue',
));
f.services.events.emit(
const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', model: 'claude-opus-4-7', color: 'blue'),
);
await tester.pump();
await tester.pump();
@@ -167,14 +154,7 @@ void main() {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
expect(find.text('Scout'), findsOneWidget);
@@ -190,24 +170,14 @@ void main() {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
f.services.messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData(
'a1',
const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000),
),
ClaudeConversation.memberStatusData('a1', const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000)),
);
await tester.pump();
await tester.pump();
@@ -226,25 +196,11 @@ void main() {
group('T-171 roster controls', () {
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Scout', String agentId = 'a1'}) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(
id: 'teammate:$name',
role: 'teammate',
sessionId: '$name-uuid',
cwd: '/repo',
team: true,
memberName: name,
));
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(
team: 't',
agentId: agentId,
name: name,
agentType: 'coder',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
return orch;
@@ -345,24 +301,10 @@ void main() {
final orch = _fakeOrchestrator();
// We cannot intercept the proc easily through the public API — verify
// injectMessage wired up by checking the managed session is the right one.
await orch.spawn(SpawnSpec(
id: 'teammate:Alpha',
role: 'teammate',
sessionId: 'alpha-uuid',
cwd: '/repo',
team: true,
memberName: 'Alpha',
));
await orch.spawn(SpawnSpec(id: 'teammate:Alpha', role: 'teammate', sessionId: 'alpha-uuid', cwd: '/repo', team: true, memberName: 'Alpha'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a2',
name: 'Alpha',
agentType: 'coder',
paneId: '%2',
color: 'green',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a2', name: 'Alpha', agentType: 'coder', paneId: '%2', color: 'green'));
await tester.pump();
await tester.pump();
@@ -391,32 +333,11 @@ void main() {
group('T-171 task list', () {
testWidgets('task list renders live from broker on changes stream', (tester) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
team: true,
memberName: 'lead',
));
await orch.spawn(SpawnSpec(
id: 'teammate:tyre',
role: 'teammate',
sessionId: 'tyre-uuid',
cwd: '/repo',
team: true,
memberName: 'tyre',
));
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', team: true, memberName: 'lead'));
await orch.spawn(SpawnSpec(id: 'teammate:tyre', role: 'teammate', sessionId: 'tyre-uuid', cwd: '/repo', team: true, memberName: 'tyre'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'lead',
agentType: 'lead',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
@@ -443,10 +364,16 @@ void main() {
// shared canSizeOverlay harness the sidebar's scrollable otherwise gets a
// degenerate viewport and clips the task section out of the semantics
// tree, so the reassign button below the roster isn't findable by label.
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(orchestrator: orch, initialTab: SidebarTab.team)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(orchestrator: orch, initialTab: SidebarTab.team),
),
),
);
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'cyan'));
await tester.pump();
await tester.pump();
@@ -462,9 +389,7 @@ void main() {
// canSizeOverlay test harness gives the sidebar's ListView a degenerate
// viewport that clips the lower task section out of the semantics *tree*
// (the widget is built and tappable; only the semantics node is dropped).
final reassign = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Reassign task',
);
final reassign = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Reassign task');
await tester.tap(reassign.first);
await tester.pump();
await tester.pump();
@@ -493,29 +418,11 @@ void main() {
return (orch, writes);
}
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(
WidgetTester tester, {
String name = 'Scout',
String agentId = 'b1',
}) async {
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(WidgetTester tester, {String name = 'Scout', String agentId = 'b1'}) async {
final (orch, writes) = orchCapturing();
await orch.spawn(SpawnSpec(
id: 'teammate:$name',
role: 'teammate',
sessionId: '$name-uuid',
cwd: '/repo',
team: true,
memberName: name,
));
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(
team: 't',
agentId: agentId,
name: name,
agentType: 'coder',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
return (orch, writes);
@@ -540,10 +447,7 @@ void main() {
f.services.messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData(
'b1',
const SessionStatus(permissionMode: 'acceptEdits'),
),
ClaudeConversation.memberStatusData('b1', const SessionStatus(permissionMode: 'acceptEdits')),
);
await tester.pump();
await tester.pump();
@@ -647,25 +551,11 @@ void main() {
group('T-172 fork session button', () {
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Forker', String agentId = 'f1'}) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(
id: 'teammate:$name',
role: 'teammate',
sessionId: '$name-uuid',
cwd: '/repo',
team: true,
memberName: name,
));
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(
team: 't',
agentId: agentId,
name: name,
agentType: 'coder',
paneId: '%1',
color: 'teal',
));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'teal'));
await tester.pump();
await tester.pump();
return orch;
@@ -794,10 +684,7 @@ void main() {
final config = await loadedConfig(
tester,
dir,
skills: [
(name: 'git-commit', dir: 'git-commit'),
(name: 'pql', dir: 'pql'),
],
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql')],
commands: ['deploy', 'test'],
agents: ['planner'],
settings: {
@@ -810,10 +697,16 @@ void main() {
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -831,18 +724,20 @@ void main() {
final config = await loadedConfig(
tester,
dir,
skills: [
(name: 'git-commit', dir: 'git-commit'),
(name: 'pql', dir: 'pql'),
(name: 'deep-research', dir: 'deep-research'),
],
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql'), (name: 'deep-research', dir: 'deep-research')],
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -865,10 +760,16 @@ void main() {
final config = await loadedConfig(tester, dir, agents: ['planner', 'coder']);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -885,10 +786,16 @@ void main() {
final config = await loadedConfig(tester, dir, commands: ['deploy', 'test', 'lint']);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -916,10 +823,16 @@ void main() {
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -947,10 +860,16 @@ void main() {
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -973,10 +892,16 @@ void main() {
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1003,10 +928,16 @@ void main() {
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1031,10 +962,16 @@ void main() {
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1055,10 +992,16 @@ void main() {
final config = await loadedConfig(tester, dir, skills: [(name: 'git-commit', dir: 'git-commit')]);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1079,10 +1022,16 @@ void main() {
final config = await loadedConfig(tester, dir);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
+33 -37
View File
@@ -76,28 +76,28 @@ void main() {
});
Widget tree(ClaudePane pane) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 900,
height: 700,
child: DialogHost(
router: f.services.dialog,
child: Overlay(initialEntries: [OverlayEntry(builder: (_) => pane)]),
),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 900,
height: 700,
child: DialogHost(
router: f.services.dialog,
child: Overlay(initialEntries: [OverlayEntry(builder: (_) => pane)]),
),
),
),
),
);
),
),
);
// Pump the pane and release its project-wait gate so _spawn runs. The whole
// chain (incl. the real transcript-probe I/O) runs in the real zone.
@@ -205,14 +205,9 @@ void main() {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(
tester,
() => proc.feed({
'type': 'system',
'subtype': 'init',
'model': 'claude-opus-4-8',
'permissionMode': 'plan',
'session_id': primarySessionId('/repo-a'),
}));
tester,
() => proc.feed({'type': 'system', 'subtype': 'init', 'model': 'claude-opus-4-8', 'permissionMode': 'plan', 'session_id': primarySessionId('/repo-a')}),
);
// The init event flows through the session into the pane's status path
// (the rendered slot lives in the status bar, absent from this harness).
expect(orch.byId('primary')!.session.status.permissionMode, 'plan');
@@ -223,17 +218,18 @@ void main() {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(
tester,
() => proc.feed({
'type': 'control_request',
'request_id': 'req-1',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'Bash',
'tool_use_id': 'tu-1',
'input': {'command': 'ls'},
},
}));
tester,
() => proc.feed({
'type': 'control_request',
'request_id': 'req-1',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'Bash',
'tool_use_id': 'tu-1',
'input': {'command': 'ls'},
},
}),
);
expect(find.byType(ClaudeComposer), findsNothing, reason: 'prompt takes the composer slot (D-78)');
});
@@ -15,29 +15,29 @@ import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
Widget _host(KernelFixture f, Key key) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
// Bounded size so the reorderable tab strip's Draggable has a
// real width and the Overlay below isn't asked to self-size.
width: 800,
height: 600,
child: Overlay(
initialEntries: [OverlayEntry(builder: (_) => ClaudeSessionHost(key: key))],
),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
// Bounded size so the reorderable tab strip's Draggable has a
// real width and the Overlay below isn't asked to self-size.
width: 800,
height: 600,
child: Overlay(
initialEntries: [OverlayEntry(builder: (_) => ClaudeSessionHost(key: key))],
),
),
),
),
);
),
),
);
void main() {
late KernelFixture f;
+2 -13
View File
@@ -67,13 +67,7 @@ void main() {
});
test('full status line with all fields (T-168)', () {
const s = SessionStatus(
model: 'claude-opus-4-7',
permissionMode: 'default',
contextTokens: 21000,
contextWindow: 1000000,
cost: 0.05,
);
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'default', contextTokens: 21000, contextWindow: 1000000, cost: 0.05);
final line = formatStatusLine(s);
expect(line, contains('opus 4.7'));
expect(line, contains('default'));
@@ -98,12 +92,7 @@ void main() {
group('statusSegmentsAroundMode (T-226)', () {
test('splits model (leading) from ctx/cost/rate (trailing), mode excluded', () {
const s = SessionStatus(
model: 'claude-opus-4-7',
permissionMode: 'plan',
contextTokens: 21000,
cost: 0.05,
);
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000, cost: 0.05);
final seg = statusSegmentsAroundMode(s);
expect(seg.leading, 'opus 4.7');
expect(seg.trailing, contains('21k ctx'));
@@ -24,10 +24,15 @@ void main() {
];
Future<void> pump(WidgetTester tester, List<TaskItem> items) async {
await tester.pumpWidget(harness(
f,
Align(alignment: Alignment.topLeft, child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items))),
));
await tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items)),
),
),
);
await tester.pump();
}
@@ -53,9 +53,7 @@ void main() {
});
test('files take precedence over an image', () async {
final result = await resolveClipboardAttachment(
_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])),
);
final result = await resolveClipboardAttachment(_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])));
expect(result.map((a) => a.path), ['/x/y']);
});
+167 -134
View File
@@ -39,15 +39,17 @@ void main() {
}
testWidgets('copy button is always in the tree (Opacity 0 before hover)', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
// The text widget is in the tree (always), but the Opacity hides it.
@@ -56,15 +58,17 @@ void main() {
});
testWidgets('copy button (on hover) writes the copyText to the clipboard', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
await hoverCard(tester);
@@ -78,15 +82,17 @@ void main() {
testWidgets('copy button: keyboard focus + ActivateIntent writes to clipboard without hovering', (tester) async {
// T-174: actions must be keyboard-reachable even when the card is not hovered.
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'keyboard written',
body: Text('keyboard written', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'keyboard written',
body: Text('keyboard written', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
// Locate the ClideTappable whose FocusNode we want: the one wrapping the
@@ -96,9 +102,7 @@ void main() {
expect(copyTextFinder, findsOneWidget);
// Walk up to find the Focus that ClideTappable installed, then request focus.
final focusWidget = tester.widget<Focus>(
find.ancestor(of: copyTextFinder, matching: find.byType(Focus)).first,
);
final focusWidget = tester.widget<Focus>(find.ancestor(of: copyTextFinder, matching: find.byType(Focus)).first);
focusWidget.focusNode!.requestFocus();
await tester.pump(); // focus resolves; the focus listener calls setState
await tester.pump(); // the scheduled rebuild paints the lifted opacity
@@ -117,15 +121,17 @@ void main() {
testWidgets('copy button carries a Semantics button label', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'msg',
body: Text('msg', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'msg',
body: Text('msg', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
// Semantics label 'copy' must be present even before hover, so AT can
// discover the button without the user mousing over first.
@@ -134,17 +140,19 @@ void main() {
});
testWidgets('collapsible card hides its body until expanded', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('tool output here'), findsNothing); // collapsed
@@ -155,14 +163,16 @@ void main() {
});
testWidgets('a non-collapsible card always shows its body and no caret', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'claude',
body: Text('always visible', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'claude',
body: Text('always visible', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('always visible'), findsOneWidget);
expect(find.bySemanticsLabel('Expand'), findsNothing);
@@ -170,15 +180,17 @@ void main() {
});
testWidgets('custom actions are always in the tree (Opacity 0 before hover)', (tester) async {
await tester.pumpWidget(harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () {})],
await tester.pumpWidget(
harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () {})],
),
),
));
);
await tester.pump();
// 'fork' is in the tree (always), but the Opacity hides it.
@@ -188,15 +200,17 @@ void main() {
testWidgets('custom actions appear on hover and invoke', (tester) async {
var forked = false;
await tester.pumpWidget(harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () => forked = true)],
await tester.pumpWidget(
harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () => forked = true)],
),
),
));
);
await tester.pump();
await hoverCard(tester);
@@ -212,19 +226,21 @@ void main() {
final showExtra = ValueNotifier<bool>(true);
addTearDown(showExtra.dispose);
await tester.pumpWidget(harness(
f,
ValueListenableBuilder<bool>(
valueListenable: showExtra,
builder: (_, show, __) => ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
copyText: show ? 'text' : null,
body: const Text('body', textDirection: TextDirection.ltr),
actions: show ? [MessageAction(label: 'fork', onInvoke: () {})] : [],
await tester.pumpWidget(
harness(
f,
ValueListenableBuilder<bool>(
valueListenable: showExtra,
builder: (_, show, _) => ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
copyText: show ? 'text' : null,
body: const Text('body', textDirection: TextDirection.ltr),
actions: show ? [MessageAction(label: 'fork', onInvoke: () {})] : [],
),
),
),
));
);
await tester.pump();
// Initially: copy + fork.
expect(find.text('copy'), findsOneWidget);
@@ -243,17 +259,19 @@ void main() {
testWidgets('collapsible card: Semantics.onTap on caret also toggles collapse', (tester) async {
// The caret Semantics node has its own onTap (for accessibility-tree callers).
// Invoke it via the Semantics.onTap callback directly.
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('tool output here'), findsNothing); // collapsed
@@ -269,15 +287,17 @@ void main() {
});
testWidgets('bordered variant renders with a border container', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFF0000),
label: 'result',
body: Text('bordered body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFF0000),
label: 'result',
body: Text('bordered body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('bordered body'), findsOneWidget);
expect(find.text('result'), findsOneWidget);
@@ -285,16 +305,18 @@ void main() {
testWidgets('status: success renders a "succeeded" semantics mark in the header (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text('body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text('body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.bySemanticsLabel('succeeded'), findsOneWidget);
expect(find.bySemanticsLabel('failed'), findsNothing);
@@ -303,16 +325,18 @@ void main() {
testWidgets('status: error renders a "failed" semantics mark; none renders nothing (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.error,
body: Text('body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.error,
body: Text('body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.bySemanticsLabel('failed'), findsOneWidget);
expect(find.bySemanticsLabel('succeeded'), findsNothing);
@@ -320,18 +344,25 @@ void main() {
});
testWidgets('extraSegments render below the body with their sub-label when expanded (T-262)', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Read',
collapsible: true,
collapsedByDefault: true,
body: Text('the call', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('the output', textDirection: TextDirection.ltr))],
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Read',
collapsible: true,
collapsedByDefault: true,
body: Text('the call', textDirection: TextDirection.ltr),
extraSegments: [
CardSegment(
label: 'result',
child: Text('the output', textDirection: TextDirection.ltr),
),
],
),
),
));
);
await tester.pump();
// Collapsed: neither the body nor the segment shows.
expect(find.text('the call'), findsNothing);
@@ -346,15 +377,17 @@ void main() {
});
testWidgets('bare variant renders content without frame decoration', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bare,
accent: Color(0xFF00FF00),
label: 'bare',
body: Text('bare body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bare,
accent: Color(0xFF00FF00),
label: 'bare',
body: Text('bare body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('bare body'), findsOneWidget);
});
@@ -34,24 +34,26 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(
width: 600,
height: 600,
child: Column(
children: [
Expanded(child: ConversationView(controller: c)),
// Stand-in for the interaction zone (composer / prompt) whose height
// changes; ValueListenableBuilder rebuilds just the box.
ValueListenableBuilder<double>(
valueListenable: bottomH,
builder: (_, h, __) => SizedBox(height: h, width: 600),
),
],
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 600,
height: 600,
child: Column(
children: [
Expanded(child: ConversationView(controller: c)),
// Stand-in for the interaction zone (composer / prompt) whose height
// changes; ValueListenableBuilder rebuilds just the box.
ValueListenableBuilder<double>(
valueListenable: bottomH,
builder: (_, h, _) => SizedBox(height: h, width: 600),
),
],
),
),
),
));
);
for (var i = 0; i < 40; i++) {
stream.add(_asst('conversation line number $i', i));
}
+80 -102
View File
@@ -148,11 +148,14 @@ void main() {
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
{Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
FoldLevel foldLevel = FoldLevel.none}) async {
Future<ConversationController> pumpWith(
WidgetTester tester,
List<ConversationItem> items, {
Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
FoldLevel foldLevel = FoldLevel.none,
}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -164,20 +167,23 @@ void main() {
addTearDown(c.dispose);
// Disable animations so an in-flight run's ClideSpinner (a perpetual
// animation) renders static and pumpAndSettle can settle (T-296).
await tester.pumpWidget(harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(
await tester.pumpWidget(
harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(
controller: c,
hiddenToolUseIds: hiddenToolUseIds,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
foldLevel: foldLevel),
foldLevel: foldLevel,
),
),
),
),
));
);
for (final it in items) {
stream.add(it);
}
@@ -198,17 +204,19 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
Builder(
// The in-flight Bash collapser shows a live spinner (T-305); disable
// animations so it renders static and pumpAndSettle can settle.
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.none),
await tester.pumpWidget(
harness(
f,
Builder(
// The in-flight Bash collapser shows a live spinner (T-305); disable
// animations so it renders static and pumpAndSettle can settle.
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.none),
),
),
),
));
);
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
stream.add(AssistantThinkingMessage(uuid: 'B', timestamp: _t, isSidechain: false, thinking: 'thinking body'));
await tester.pumpAndSettle();
@@ -226,15 +234,17 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.tools),
await tester.pumpWidget(
harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.tools),
),
),
),
));
);
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'}));
await tester.pumpAndSettle();
@@ -286,15 +296,7 @@ void main() {
AssistantToolUse edit(String id, String path) =>
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path});
ToolResultMessage ok(String id) => ToolResultMessage(uuid: 'r$id', timestamp: _t, isSidechain: false, toolUseId: id, content: 'done', isError: false);
await pumpWith(
tester,
[
edit('e1', '/lib/x.dart'),
ok('e1'),
edit('e2', '/lib/x.dart'),
ok('e2'),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [edit('e1', '/lib/x.dart'), ok('e1'), edit('e2', '/lib/x.dart'), ok('e2')], foldLevel: FoldLevel.tools);
// One bundled card labelled "2 edits" with an aggregate status indicator.
expect(find.text('2 edits'), findsOneWidget);
expect(find.byType(ClideStatusIndicator), findsOneWidget);
@@ -309,15 +311,12 @@ void main() {
});
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
// A second, distinct in-flight tool call (T-262 folds a success
// result into its call card, so two *calls* are what make 2 steps).
AssistantToolUse(uuid: 'tu2', timestamp: _t, isSidechain: false, toolUseId: 'x2', name: 'Read', input: const {'file_path': '/a'}),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [
_tool('Bash', const {'command': 'echo hi'}),
// A second, distinct in-flight tool call (T-262 folds a success
// result into its call card, so two *calls* are what make 2 steps).
AssistantToolUse(uuid: 'tu2', timestamp: _t, isSidechain: false, toolUseId: 'x2', name: 'Read', input: const {'file_path': '/a'}),
], foldLevel: FoldLevel.tools);
// Collapsed by default: one card with a step count, not the raw rows.
expect(find.text('2 steps'), findsOneWidget);
expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget);
@@ -328,13 +327,10 @@ void main() {
});
testWidgets('a folded success result is not a separate step — merged into its call (T-262 note D)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
_result('hi there'),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [
_tool('Bash', const {'command': 'echo hi'}),
_result('hi there'),
], foldLevel: FoldLevel.tools);
// The call + its success result is ONE unit now: 1 step, not 2.
expect(find.text('1 step'), findsOneWidget);
expect(find.text('2 steps'), findsNothing);
@@ -360,13 +356,10 @@ void main() {
});
testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'boom'}),
_result('error output', isError: true),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [
_tool('Bash', const {'command': 'boom'}),
_result('error output', isError: true),
], foldLevel: FoldLevel.tools);
// The tool call folds (1 step); the error result is sticky → no 2-step card.
expect(find.text('1 step'), findsOneWidget);
expect(find.textContaining('error output'), findsWidgets);
@@ -376,13 +369,7 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
ConversationView(
controller: c,
emptyState: const ClideText('CUSTOM EMPTY'),
),
));
await tester.pumpWidget(harness(f, ConversationView(controller: c, emptyState: const ClideText('CUSTOM EMPTY'))));
expect(find.text('CUSTOM EMPTY'), findsOneWidget);
expect(find.text('Waiting for Claude…'), findsNothing);
});
@@ -421,7 +408,13 @@ void main() {
testWidgets('a sidechain prompt folds into its Agent card; never labelled "you" (T-263)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(
uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore the codebase'}),
uuid: 'agt-msg',
timestamp: _t,
isSidechain: false,
toolUseId: 'task1',
name: 'Task',
input: const {'description': 'explore the codebase'},
),
UserMessage(uuid: 'p1', timestamp: _t, isSidechain: true, text: 'find all the widgets'),
]);
// Never the blue "you", and no standalone block (folded → suppressed).
@@ -458,9 +451,7 @@ void main() {
testWidgets('an orphan sidechain prompt renders as muted "agent prompt", never "you" (T-263)', (tester) async {
// No Agent tool-use to attach to → stays standalone, but relabelled.
await pumpWith(tester, [
UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions'),
]);
await pumpWith(tester, [UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions')]);
expect(find.text('you'), findsNothing);
expect(find.text('agent prompt'), findsOneWidget);
});
@@ -490,7 +481,14 @@ void main() {
UserMessage(uuid: 'p', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'go explore'),
// A sidechain tool call — part of the run, chained off the prompt.
AssistantToolUse(
uuid: 's1', timestamp: _t, isSidechain: true, parentUuid: 'p', toolUseId: 'sb', name: 'Bash', input: const {'command': 'grep widgets'}),
uuid: 's1',
timestamp: _t,
isSidechain: true,
parentUuid: 'p',
toolUseId: 'sb',
name: 'Bash',
input: const {'command': 'grep widgets'},
),
]);
expect(find.text('Task'), findsOneWidget);
// The run is a nested holder titled "agent run", collapsed by default —
@@ -550,17 +548,13 @@ void main() {
testWidgets('sidechain assistant prose is attributed to "agent", not "claude" (T-265)', (tester) async {
// An orphan sidechain prose (no resolvable Agent) renders inline, still
// attributed to the agent — never the main-thread coral "claude".
await pumpWith(tester, [
AssistantTextMessage(uuid: 's', timestamp: _t, isSidechain: true, text: 'sub-agent says hi'),
]);
await pumpWith(tester, [AssistantTextMessage(uuid: 's', timestamp: _t, isSidechain: true, text: 'sub-agent says hi')]);
expect(find.text('agent'), findsOneWidget);
expect(find.text('claude'), findsNothing);
});
testWidgets('sidechain thinking is attributed to "agent thinking" (T-265)', (tester) async {
await pumpWith(tester, [
AssistantThinkingMessage(uuid: 's', timestamp: _t, isSidechain: true, thinking: 'hmm let me think'),
]);
await pumpWith(tester, [AssistantThinkingMessage(uuid: 's', timestamp: _t, isSidechain: true, thinking: 'hmm let me think')]);
expect(find.text('agent thinking'), findsOneWidget);
expect(find.text('thinking'), findsNothing);
});
@@ -578,7 +572,7 @@ void main() {
tester,
[
_tool('Write', {'file_path': '/tmp/x'}),
_result('done')
_result('done'),
],
hiddenToolUseIds: {'x1'}, // _tool + _result both use toolUseId 'x1'
);
@@ -593,7 +587,7 @@ void main() {
tester,
[
_tool('Write', {'file_path': '/tmp/x'}),
_result('done')
_result('done'),
],
hiddenToolUseIds: {'x1'},
toolUseOutcomes: {'x1': true}, // approved
@@ -619,7 +613,7 @@ void main() {
testWidgets('tool-use body: Bash shows the command in the collapsed summary (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'ls -la'})
_tool('Bash', {'command': 'ls -la'}),
]);
// Collapser starts collapsed — the command appears as the echoed summary.
expect(find.text('ls -la'), findsOneWidget);
@@ -632,7 +626,7 @@ void main() {
testWidgets('tool-use body: Read/Grep/LS shows a compact path label (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Read', {'file_path': '/foo/bar.dart'})
_tool('Read', {'file_path': '/foo/bar.dart'}),
]);
// The path label appears (collapsed summary or body).
expect(find.text('/foo/bar.dart'), findsOneWidget);
@@ -665,7 +659,7 @@ void main() {
final handle = tester.ensureSemantics();
// In-flight: a call with no result yet → no check, no folded result.
await pumpWith(tester, [
_tool('Bash', {'command': 'ls'})
_tool('Bash', {'command': 'ls'}),
]);
expect(find.bySemanticsLabel('succeeded'), findsNothing);
expect(find.bySemanticsLabel('failed'), findsNothing);
@@ -734,16 +728,7 @@ void main() {
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
// Orphan result (no matching tool_use in the controller).
await pumpWith(tester, [
ToolResultMessage(
uuid: 'r-orphan',
timestamp: _t,
isSidechain: false,
toolUseId: 'unknown-id',
content: 'ok',
isError: false,
),
]);
await pumpWith(tester, [ToolResultMessage(uuid: 'r-orphan', timestamp: _t, isSidechain: false, toolUseId: 'unknown-id', content: 'ok', isError: false)]);
expect(find.text('result'), findsOneWidget);
});
@@ -811,14 +796,7 @@ void main() {
tearDown(() => f.dispose());
testWidgets('shows role, workspace, status, and a hint', (tester) async {
await tester.pumpWidget(harness(
f,
const ClaudeBanner(
role: 'primary',
workspace: '/work/space',
statusLine: 'tmux · clide-claude-x',
),
));
await tester.pumpWidget(harness(f, const ClaudeBanner(role: 'primary', workspace: '/work/space', statusLine: 'tmux · clide-claude-x')));
await tester.pump();
expect(find.text('Claude'), findsOneWidget);
expect(find.text('primary'), findsOneWidget);
@@ -24,30 +24,30 @@ import '../../helpers/widget_harness.dart';
/// the minimum theme/kernel tree under a tight, [Align]ed [SizedBox] so the
/// `Flexible` gets a real bounded width from the surrounding Row.
Widget _narrowRow(KernelFixture f, double width, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width,
height: 24,
child: Row(
children: [
Flexible(flex: 1, fit: FlexFit.loose, child: child),
const SizedBox(width: 20), // simulated right-side items
],
),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width,
height: 24,
child: Row(
children: [
Flexible(flex: 1, fit: FlexFit.loose, child: child),
const SizedBox(width: 20), // simulated right-side items
],
),
),
),
),
);
),
),
);
void main() {
late KernelFixture f;
@@ -76,11 +76,7 @@ void main() {
testWidgets('no RenderFlex overflow at narrow width — ClideMarquee is bounded', (tester) async {
// A long status line similar to what T-154 added:
// "opus 4.7 · default · 21k ctx · 10 skills" — roughly 280 px of text.
const longStatus = Text(
'opus 4.7 · default · 21k ctx · 10 skills',
textDirection: TextDirection.ltr,
softWrap: false,
);
const longStatus = Text('opus 4.7 · default · 21k ctx · 10 skills', textDirection: TextDirection.ltr, softWrap: false);
// Pump at 200 px wide — narrower than the status content so the marquee
// must receive a bounded viewport < content width and start scrolling.
@@ -18,10 +18,15 @@ void main() {
tearDown(() async => f.dispose());
Future<void> pump(WidgetTester tester, String mode, ValueChanged<String> onSelect) {
return tester.pumpWidget(harness(
f,
Align(alignment: Alignment.center, child: PermissionModeControl(mode: mode, onSelect: onSelect)),
));
return tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.center,
child: PermissionModeControl(mode: mode, onSelect: onSelect),
),
),
);
}
testWidgets('opens a menu of the safe trio + a bypass row; selecting sets the mode', (tester) async {
@@ -70,16 +75,9 @@ void main() {
tearDown(() async => f.dispose());
testWidgets('the mode control and the Stop row coexist while busy', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
busy: true,
onInterrupt: () {},
permissionMode: 'default',
onSetPermissionMode: (_) {},
),
));
await tester.pumpWidget(
harness(f, ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () {}, permissionMode: 'default', onSetPermissionMode: (_) {})),
);
await tester.pump();
// Stop affordance (busy row) and the trailing mode control are both present.
expect(find.textContaining('Stop'), findsOneWidget);
+93 -112
View File
@@ -9,60 +9,60 @@ import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
ToolPrompt permissionPrompt({List<dynamic> suggestions = const []}) => ToolPrompt(
promptId: 'req-1',
toolName: 'Write',
displayName: 'Write',
description: 'banana.txt',
input: const {'file_path': '/tmp/banana.txt', 'content': 'banana'},
permissionSuggestions: suggestions,
);
promptId: 'req-1',
toolName: 'Write',
displayName: 'Write',
description: 'banana.txt',
input: const {'file_path': '/tmp/banana.txt', 'content': 'banana'},
permissionSuggestions: suggestions,
);
ToolPrompt questionPrompt({bool multi = false}) => ToolPrompt(
promptId: 'req-q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Do you prefer cats or dogs?',
'header': 'Pet',
'multiSelect': multi,
'options': [
{'label': 'Cats', 'description': 'cat person'},
{'label': 'Dogs', 'description': 'dog person'},
],
},
promptId: 'req-q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Do you prefer cats or dogs?',
'header': 'Pet',
'multiSelect': multi,
'options': [
{'label': 'Cats', 'description': 'cat person'},
{'label': 'Dogs', 'description': 'dog person'},
],
},
);
],
},
);
ToolPrompt twoQuestionPrompt() => const ToolPrompt(
promptId: 'req-2q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Which pet?',
'header': 'Pet',
'multiSelect': false,
'options': [
{'label': 'Cats', 'description': ''},
{'label': 'Dogs', 'description': ''},
],
},
{
'question': 'How eaten?',
'header': 'Eaten',
'multiSelect': false,
'options': [
{'label': 'Fresh', 'description': ''},
{'label': 'Smoothie', 'description': ''},
],
},
promptId: 'req-2q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Which pet?',
'header': 'Pet',
'multiSelect': false,
'options': [
{'label': 'Cats', 'description': ''},
{'label': 'Dogs', 'description': ''},
],
},
);
{
'question': 'How eaten?',
'header': 'Eaten',
'multiSelect': false,
'options': [
{'label': 'Fresh', 'description': ''},
{'label': 'Smoothie', 'description': ''},
],
},
],
},
);
void main() {
late KernelFixture f;
@@ -72,16 +72,18 @@ void main() {
testWidgets('permission card: Allow returns AllowTool echoing the input', (tester) async {
ToolDecision? decision;
String? id;
await tester.pumpWidget(harness(
f,
ToolPromptCard(
prompt: permissionPrompt(),
onResolve: (p, d) {
id = p;
decision = d;
},
await tester.pumpWidget(
harness(
f,
ToolPromptCard(
prompt: permissionPrompt(),
onResolve: (p, d) {
id = p;
decision = d;
},
),
),
));
);
await tester.pump();
expect(find.text('permission · Write'), findsOneWidget);
@@ -97,7 +99,7 @@ void main() {
});
testWidgets('permission card shows the command being permitted', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, _) {})));
await tester.pump();
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
@@ -110,7 +112,7 @@ void main() {
description: 'Read IDE lock files',
input: {'command': 'cat ~/.claude/ide/97632.lock', 'description': 'Read IDE lock files'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
final block = tester.widget<ClideCodeBlock>(find.byType(ClideCodeBlock));
@@ -129,7 +131,7 @@ void main() {
description: 'long task',
input: {'command': 'sleep 30', 'run_in_background': true, 'timeout': 60000},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
expect(find.text('background · timeout 60000ms'), findsOneWidget);
});
@@ -145,7 +147,7 @@ void main() {
description: '/tmp/clide-ux-test.txt',
input: {'file_path': '/tmp/clide-ux-test.txt', 'content': 'hello'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
// The path should appear exactly once (in _pathLine, inside the body).
@@ -160,7 +162,7 @@ void main() {
description: 'banana.txt',
input: {'file_path': '/tmp/banana.txt', 'content': 'banana'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
expect(find.text('banana.txt'), findsOneWidget);
@@ -168,13 +170,8 @@ void main() {
});
testWidgets('permission card: unknown tool falls back to JSON', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-x',
toolName: 'NovelTool',
displayName: 'NovelTool',
input: {'foo': 'bar'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
const prompt = ToolPrompt(promptId: 'req-x', toolName: 'NovelTool', displayName: 'NovelTool', input: {'foo': 'bar'});
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
final block = tester.widget<ClideCodeBlock>(find.byType(ClideCodeBlock));
expect(block.language, 'json');
@@ -183,10 +180,7 @@ void main() {
testWidgets('permission card: Deny returns DenyTool with a message', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.text('2. Deny'));
@@ -197,7 +191,7 @@ void main() {
});
testWidgets('permission: no "don\'t ask again" button without a suggestion', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, _) {})));
await tester.pump();
expect(find.text("2. Allow & don't ask again"), findsNothing);
});
@@ -205,12 +199,17 @@ void main() {
testWidgets('permission: "don\'t ask again" shows with a suggestion and returns updatedPermissions', (tester) async {
ToolDecision? decision;
const sugg = [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'},
];
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: permissionPrompt(suggestions: sugg), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(
harness(
f,
ToolPromptCard(
prompt: permissionPrompt(suggestions: sugg),
onResolve: (_, d) => decision = d,
),
),
);
await tester.pump();
expect(find.text("2. Allow & don't ask again"), findsOneWidget);
@@ -272,10 +271,7 @@ void main() {
testWidgets('question card: Submit is gated until an option is picked, then returns answers', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
expect(find.text('Do you prefer cats or dogs?'), findsOneWidget);
@@ -297,10 +293,7 @@ void main() {
testWidgets('question card: multi-select joins chosen labels comma-separated', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: questionPrompt(multi: true), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(multi: true), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.textContaining('Cats'));
@@ -396,13 +389,9 @@ void main() {
promptId: 'req-e',
toolName: 'Edit',
displayName: 'Edit',
input: {
'file_path': '/tmp/foo.dart',
'old_string': 'void main() {}',
'new_string': 'void main() => run();',
},
input: {'file_path': '/tmp/foo.dart', 'old_string': 'void main() {}', 'new_string': 'void main() => run();'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
// Two code blocks: before + after.
expect(find.byType(ClideCodeBlock), findsNWidgets(2));
@@ -413,13 +402,8 @@ void main() {
group('permission card: Read/Grep show compact path via shared helper', () {
testWidgets('Read shows the file path label', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-r',
toolName: 'Read',
displayName: 'Read',
input: {'file_path': '/docs/readme.md'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
const prompt = ToolPrompt(promptId: 'req-r', toolName: 'Read', displayName: 'Read', input: {'file_path': '/docs/readme.md'});
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
expect(find.text('/docs/readme.md'), findsOneWidget);
// No code blocks — just a text label for Read.
@@ -428,13 +412,8 @@ void main() {
testWidgets('Grep shows pattern quoted alongside any path', (tester) async {
// Grep with both path and pattern: the label shows file_path + quoted pattern.
const prompt = ToolPrompt(
promptId: 'req-grep',
toolName: 'Grep',
displayName: 'Grep',
input: {'pattern': 'TODO', 'path': '/src'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
const prompt = ToolPrompt(promptId: 'req-grep', toolName: 'Grep', displayName: 'Grep', input: {'pattern': 'TODO', 'path': '/src'});
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
// The combined label contains both the path and the quoted pattern.
expect(find.textContaining('"TODO"'), findsOneWidget);
@@ -449,13 +428,15 @@ void main() {
final notifier = ValueNotifier<ToolPrompt>(questionPrompt());
addTearDown(notifier.dispose);
await tester.pumpWidget(harness(
f,
ValueListenableBuilder<ToolPrompt>(
valueListenable: notifier,
builder: (_, p, __) => ToolPromptCard(prompt: p, onResolve: (_, d) => decision = d),
await tester.pumpWidget(
harness(
f,
ValueListenableBuilder<ToolPrompt>(
valueListenable: notifier,
builder: (_, p, _) => ToolPromptCard(prompt: p, onResolve: (_, d) => decision = d),
),
),
));
);
await tester.pump();
expect(find.text('Do you prefer cats or dogs?'), findsOneWidget);
@@ -530,7 +511,7 @@ void main() {
});
const sugg = [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'},
];
testWidgets('with a remember suggestion: 2 = Allow & remember', (tester) async {
@@ -22,9 +22,9 @@ void main() {
tearDown(() => f.dispose());
Widget wrap({bool reducedMotion = false}) => MediaQuery(
data: MediaQueryData(disableAnimations: reducedMotion),
child: const RunningIndicator(shuffle: false),
);
data: MediaQueryData(disableAnimations: reducedMotion),
child: const RunningIndicator(shuffle: false),
);
testWidgets('animates the ellipsis and rotates the verb', (tester) async {
await tester.pumpWidget(harness(f, wrap()));
+28 -33
View File
@@ -5,39 +5,39 @@ import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:test/test.dart';
String userLine(String text) => jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
});
'type': 'user',
'message': {'role': 'user', 'content': text},
});
String userBlocksLine(String text) => jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': text},
],
},
});
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': text},
],
},
});
String toolResultLine() => jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'tool_result', 'content': 'output'},
],
},
});
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'tool_result', 'content': 'output'},
],
},
});
String assistantLine(String text) => jsonEncode({
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
void main() {
group('userText', () {
@@ -69,12 +69,7 @@ void main() {
});
test('summarises each session with first … last bookends', () async {
await writeSession('aaaa', [
userLine('start the swallow'),
assistantLine('ok'),
toolResultLine(),
userLine('now the peacock'),
]);
await writeSession('aaaa', [userLine('start the swallow'), assistantLine('ok'), toolResultLine(), userLine('now the peacock')]);
final sessions = await listSessions(dir);
expect(sessions, hasLength(1));
expect(sessions.single.id, 'aaaa');
@@ -47,14 +47,8 @@ ClaudeSessionOrchestrator _orch(List<_FakeProc> created) {
);
}
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) => SpawnSpec(
id: id,
role: id,
sessionId: '$id-uuid',
cwd: cwd,
resume: resume,
transcriptPath: transcriptPath,
);
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) =>
SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: cwd, resume: resume, transcriptPath: transcriptPath);
// ---------------------------------------------------------------------------
// Tests
@@ -195,11 +189,7 @@ void main() {
'"message":{"role":"user","content":"hello from the past"}}\n',
);
final managed = await orch.spawn(_spec(
'primary',
resume: true,
transcriptPath: file.path,
));
final managed = await orch.spawn(_spec('primary', resume: true, transcriptPath: file.path));
expect(managed.conversation.items, hasLength(1));
await tmp.delete(recursive: true);
@@ -210,13 +200,7 @@ void main() {
await orch.close('primary');
// /resume picked a past session id; re-spawn with resume:true.
final picked = SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'picked-past-uuid',
cwd: '/repo',
resume: true,
);
final picked = SpawnSpec(id: 'primary', role: 'primary', sessionId: 'picked-past-uuid', cwd: '/repo', resume: true);
final managed = await orch.spawn(picked);
expect(managed.sessionId, 'picked-past-uuid');
expect(created, hasLength(2));
@@ -278,22 +262,8 @@ void main() {
});
test('kill-all includes team sessions', () async {
await orch.spawn(SpawnSpec(
id: 'primary',
role: 'lead',
sessionId: 'primary-uuid',
cwd: '/repo',
team: true,
memberName: 'lead',
));
await orch.spawn(SpawnSpec(
id: 'teammate:tyre',
role: 'teammate',
sessionId: 'tyre-uuid',
cwd: '/repo',
team: true,
memberName: 'tyre',
));
await orch.spawn(SpawnSpec(id: 'primary', role: 'lead', sessionId: 'primary-uuid', cwd: '/repo', team: true, memberName: 'lead'));
await orch.spawn(SpawnSpec(id: 'teammate:tyre', role: 'teammate', sessionId: 'tyre-uuid', cwd: '/repo', team: true, memberName: 'tyre'));
expect(orch.sessions, hasLength(2));
// Broker has 2 agent members + 1 virtual 'user' member (T-180).
final agentMembers = orch.broker.members.where((m) => m.id != 'user');
@@ -26,11 +26,13 @@ void main() {
setUp(() {
created = [];
orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
return p;
});
orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
return p;
},
);
});
SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible);
@@ -44,21 +46,9 @@ void main() {
});
test('folds the real claude session id from the init event into the session (T-185)', () async {
final m = await orch.spawn(SpawnSpec(
id: 'fork-x',
role: 'teammate',
sessionId: 'placeholder-uuid',
cwd: '/repo',
forkSourceSessionId: 'source-uuid',
));
final m = await orch.spawn(SpawnSpec(id: 'fork-x', role: 'teammate', sessionId: 'placeholder-uuid', cwd: '/repo', forkSourceSessionId: 'source-uuid'));
expect(m.sessionId, 'placeholder-uuid'); // starts as the placeholder
created.last.emit(jsonEncode({
'type': 'system',
'subtype': 'init',
'session_id': 'real-fork-id',
'model': 'claude-opus-4-8',
'permissionMode': 'default',
}));
created.last.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'real-fork-id', 'model': 'claude-opus-4-8', 'permissionMode': 'default'}));
await Future<void>.delayed(Duration.zero);
expect(m.sessionId, 'real-fork-id'); // updated to the branch's real id
});
@@ -161,14 +151,9 @@ void main() {
'{"type":"assistant","uuid":"a1","timestamp":"2026-05-26T00:00:01Z","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"hi back"}]}}\n',
);
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
resume: true,
transcriptPath: file.path,
));
final managed = await orch.spawn(
SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', resume: true, transcriptPath: file.path),
);
final items = managed.conversation.items;
expect(items, hasLength(2));
expect(items.first, isA<UserMessage>());
@@ -179,26 +164,23 @@ void main() {
});
test('non-resume spawn does not read the transcript', () async {
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
// resume:false → transcriptPath ignored even if set
transcriptPath: '/does/not/exist.jsonl',
));
final managed = await orch.spawn(
SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
// resume:false → transcriptPath ignored even if set
transcriptPath: '/does/not/exist.jsonl',
),
);
expect(managed.conversation.items, isEmpty);
});
test('missing transcript file is tolerated (best-effort hydration)', () async {
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
resume: true,
transcriptPath: '/does/not/exist.jsonl',
));
final managed = await orch.spawn(
SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', resume: true, transcriptPath: '/does/not/exist.jsonl'),
);
expect(managed.conversation.items, isEmpty);
});
});
@@ -218,13 +200,8 @@ void main() {
);
});
SpawnSpec forkSpec(String id, String sourceSessionId) => SpawnSpec(
id: id,
role: 'fork',
sessionId: '$id-placeholder',
cwd: '/repo',
forkSourceSessionId: sourceSessionId,
);
SpawnSpec forkSpec(String id, String sourceSessionId) =>
SpawnSpec(id: id, role: 'fork', sessionId: '$id-placeholder', cwd: '/repo', forkSourceSessionId: sourceSessionId);
test('fork spawn passes --resume <source> --fork-session instead of --session-id', () async {
const sourceId = 'bbbb2222-2222-4222-8222-222222222222';
@@ -288,12 +265,7 @@ void main() {
});
test('ManagedSession.cwd reflects the spec cwd', () async {
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/my/project',
));
final managed = await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/my/project'));
expect(managed.cwd, '/my/project');
});
});
+7 -19
View File
@@ -27,16 +27,13 @@ void main() {
tearDown(() => f.dispose());
List<SessionSummary> two() => [
SessionSummary(id: 'aaa', modified: DateTime.now(), firstUser: 'first a', lastUser: 'last a'),
SessionSummary(id: 'bbb', modified: DateTime.now(), firstUser: 'first b', lastUser: 'last b'),
];
SessionSummary(id: 'aaa', modified: DateTime.now(), firstUser: 'first a', lastUser: 'last a'),
SessionSummary(id: 'bbb', modified: DateTime.now(), firstUser: 'first b', lastUser: 'last b'),
];
testWidgets('renders first … last labels and picks with arrow + Enter', (tester) async {
String? picked;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {})));
await tester.pump();
expect(find.text('first a … last a'), findsOneWidget);
expect(find.text('first b … last b'), findsOneWidget);
@@ -49,10 +46,7 @@ void main() {
testWidgets('Escape cancels', (tester) async {
var cancelled = false;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (_) {}, onCancel: () => cancelled = true),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: two(), onPick: (_) {}, onCancel: () => cancelled = true)));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(cancelled, isTrue);
@@ -60,20 +54,14 @@ void main() {
testWidgets('tap picks a row', (tester) async {
String? picked;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {})));
await tester.pump();
await tester.tap(find.text('first b … last b'));
expect(picked, 'bbb');
});
testWidgets('empty list shows a message', (tester) async {
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: const [], onPick: (_) {}, onCancel: () {}),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: const [], onPick: (_) {}, onCancel: () {})));
await tester.pump();
expect(find.text('No sessions found for this workspace.'), findsOneWidget);
});
+7 -27
View File
@@ -17,15 +17,9 @@ void main() {
testWidgets('shows the total and a two-click delete that calls the deleter', (tester) async {
final deleted = <String>[];
await tester.pumpWidget(harness(
f,
SessionStorageDialog(
dir: Directory.systemTemp,
sessions: [session('aaa', 2048)],
onClose: () {},
deleter: (d, id) async => deleted.add(id),
),
));
await tester.pumpWidget(
harness(f, SessionStorageDialog(dir: Directory.systemTemp, sessions: [session('aaa', 2048)], onClose: () {}, deleter: (d, id) async => deleted.add(id))),
);
await tester.pump();
expect(find.text('Session storage · 2 KB total'), findsOneWidget);
@@ -44,30 +38,16 @@ void main() {
testWidgets('Escape closes', (tester) async {
var closed = false;
await tester.pumpWidget(harness(
f,
SessionStorageDialog(
dir: Directory.systemTemp,
sessions: [session('aaa', 1024)],
onClose: () => closed = true,
deleter: (_, __) async {},
),
));
await tester.pumpWidget(
harness(f, SessionStorageDialog(dir: Directory.systemTemp, sessions: [session('aaa', 1024)], onClose: () => closed = true, deleter: (_, _) async {})),
);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(closed, isTrue);
});
testWidgets('empty list shows a message', (tester) async {
await tester.pumpWidget(harness(
f,
SessionStorageDialog(
dir: Directory.systemTemp,
sessions: const [],
onClose: () {},
deleter: (_, __) async {},
),
));
await tester.pumpWidget(harness(f, SessionStorageDialog(dir: Directory.systemTemp, sessions: const [], onClose: () {}, deleter: (_, _) async {})));
await tester.pump();
expect(find.text('No sessions found for this workspace.'), findsOneWidget);
});
+177 -177
View File
@@ -28,12 +28,12 @@ class _FakeMcpServer implements McpServer {
final List<String> calls = [];
@override
List<Map<String, dynamic>> get tools => [
{
'name': 'ping',
'description': 'p',
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}},
},
];
{
'name': 'ping',
'description': 'p',
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}},
},
];
@override
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments) async {
calls.add(name);
@@ -47,114 +47,101 @@ class _FakeMcpServer implements McpServer {
}
String mcpMessage(String rid, Map<String, dynamic> message, {String server = 'clide-team'}) => jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'mcp_message', 'server_name': server, 'message': message},
});
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'mcp_message', 'server_name': server, 'message': message},
});
String assistantText(String text) => jsonEncode({
'type': 'assistant',
'uuid': 'a1',
'message': {
'model': 'claude-opus-4-7',
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
'usage': {'input_tokens': 100, 'cache_read_input_tokens': 50, 'cache_creation_input_tokens': 0},
},
});
'type': 'assistant',
'uuid': 'a1',
'message': {
'model': 'claude-opus-4-7',
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
'usage': {'input_tokens': 100, 'cache_read_input_tokens': 50, 'cache_creation_input_tokens': 0},
},
});
String assistantToolUse() => jsonEncode({
'type': 'assistant',
'uuid': 'a2',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': 't1',
'name': 'Bash',
'input': {'command': 'ls'}
},
],
'type': 'assistant',
'uuid': 'a2',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': 't1',
'name': 'Bash',
'input': {'command': 'ls'},
},
});
],
},
});
String initEvent() => jsonEncode({
'type': 'system',
'subtype': 'init',
'model': 'claude-opus-4-7',
'permissionMode': 'default',
});
String initEvent() => jsonEncode({'type': 'system', 'subtype': 'init', 'model': 'claude-opus-4-7', 'permissionMode': 'default'});
String resultEvent({double? cost, Map<String, dynamic>? modelUsage}) => jsonEncode({
'type': 'result',
'result': '',
'usage': <String, dynamic>{},
if (cost != null) 'total_cost_usd': cost,
if (modelUsage != null) 'modelUsage': modelUsage,
});
String resultEvent({double? cost, Map<String, dynamic>? modelUsage}) =>
jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}, 'total_cost_usd': ?cost, 'modelUsage': ?modelUsage});
String rateLimitEvent({String? status, String? resetsAt}) => jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': <String, dynamic>{
if (status != null) 'status': status,
if (resetsAt != null) 'resetsAt': resetsAt,
},
});
'type': 'rate_limit_event',
'rate_limit_info': <String, dynamic>{'status': ?status, 'resetsAt': ?resetsAt},
});
// Real `--include-partial-messages` wire shape (captured from claude 2.1.150,
// interactive stream-json mode — T-184): partials arrive as `stream_event`
// envelopes wrapping Anthropic streaming deltas, NOT `assistant`+`partial:true`.
String streamMessageStart(String messageId) => jsonEncode({
'type': 'stream_event',
'event': {
'type': 'message_start',
'message': {'id': messageId, 'role': 'assistant', 'content': <dynamic>[]},
},
});
'type': 'stream_event',
'event': {
'type': 'message_start',
'message': {'id': messageId, 'role': 'assistant', 'content': <dynamic>[]},
},
});
String streamTextDelta(String text, {int index = 0}) => jsonEncode({
'type': 'stream_event',
'event': {
'type': 'content_block_delta',
'index': index,
'delta': {'type': 'text_delta', 'text': text},
},
});
'type': 'stream_event',
'event': {
'type': 'content_block_delta',
'index': index,
'delta': {'type': 'text_delta', 'text': text},
},
});
String streamMessageStop() => jsonEncode({
'type': 'stream_event',
'event': {'type': 'message_stop'},
});
'type': 'stream_event',
'event': {'type': 'message_stop'},
});
// The final per-block `assistant` event carrying a message id (so the session
// can pair it with a streamed placeholder).
String assistantTextWithId(String messageId, String text, {String uuid = 'final-uuid'}) => jsonEncode({
'type': 'assistant',
'uuid': uuid,
'message': {
'id': messageId,
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
'type': 'assistant',
'uuid': uuid,
'message': {
'id': messageId,
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
String canUseTool(String rid, {String tool = 'Write', Map<String, dynamic>? input}) => jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {
'subtype': 'can_use_tool',
'tool_name': tool,
'display_name': tool,
'description': 'banana.txt',
'input': input ?? {'file_path': '/tmp/banana.txt', 'content': 'banana'},
'tool_use_id': 'toolu_1',
},
});
'type': 'control_request',
'request_id': rid,
'request': {
'subtype': 'can_use_tool',
'tool_name': tool,
'display_name': tool,
'description': 'banana.txt',
'input': input ?? {'file_path': '/tmp/banana.txt', 'content': 'banana'},
'tool_use_id': 'toolu_1',
},
});
void main() {
late _FakeProc proc;
@@ -209,13 +196,7 @@ void main() {
test('captures the claude session id from the first event carrying it (T-185)', () async {
final ids = <String>[];
session.sessionIdResolved.listen(ids.add);
proc.emit(jsonEncode({
'type': 'system',
'subtype': 'init',
'session_id': 'sess-abc',
'model': 'claude-opus-4-7',
'permissionMode': 'default',
}));
proc.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'sess-abc', 'model': 'claude-opus-4-7', 'permissionMode': 'default'}));
await Future<void>.delayed(Duration.zero);
expect(session.claudeSessionId, 'sess-abc');
expect(ids, ['sess-abc']);
@@ -229,12 +210,14 @@ void main() {
});
test('result event with modelUsage populates contextWindow', () async {
proc.emit(resultEvent(
cost: 0.01,
modelUsage: {
'claude-opus-4-7': {'contextWindow': 1000000, 'maxOutputTokens': 8192},
},
));
proc.emit(
resultEvent(
cost: 0.01,
modelUsage: {
'claude-opus-4-7': {'contextWindow': 1000000, 'maxOutputTokens': 8192},
},
),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.contextWindow, 1000000);
});
@@ -275,10 +258,12 @@ void main() {
test('rate_limit_event with a numeric (epoch) resetsAt does not crash', () async {
// Claude sends resetsAt as a unix-epoch number, not a string — the
// old `as String?` cast threw 'int is not a subtype of String?'.
proc.emit(jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': {'status': 'rate_limited', 'resetsAt': 1780000000},
}));
proc.emit(
jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': {'status': 'rate_limited', 'resetsAt': 1780000000},
}),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.rateLimitInfo, contains('rate limited'));
expect(statuses.last.rateLimitInfo, contains('resets'));
@@ -361,31 +346,35 @@ void main() {
});
test('a synthetic user message (skill/command inject) is flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'isSynthetic': true,
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'Base directory for this skill: /x'}
],
},
}));
proc.emit(
jsonEncode({
'type': 'user',
'isSynthetic': true,
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'Base directory for this skill: /x'},
],
},
}),
);
await Future<void>.delayed(Duration.zero);
final u = items.whereType<UserMessage>().single;
expect(u.injected, isTrue);
});
test('a plain user text event is not flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'hello'}
],
},
}));
proc.emit(
jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'hello'},
],
},
}),
);
await Future<void>.delayed(Duration.zero);
expect(items.whereType<UserMessage>().single.injected, isFalse);
});
@@ -428,18 +417,20 @@ void main() {
});
test('a permission request carries its permission_suggestions', () async {
proc.emit(jsonEncode({
'type': 'control_request',
'request_id': 'rs',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'Write',
'input': {'file_path': '/tmp/x'},
'permission_suggestions': [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
],
},
}));
proc.emit(
jsonEncode({
'type': 'control_request',
'request_id': 'rs',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'Write',
'input': {'file_path': '/tmp/x'},
'permission_suggestions': [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'},
],
},
}),
);
await Future<void>.delayed(Duration.zero);
expect(session.pendingPrompt!.permissionSuggestions, hasLength(1));
});
@@ -448,12 +439,14 @@ void main() {
proc.emit(canUseTool('rp'));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt(
'rp',
AllowTool(const {
'x': 1
}, updatedPermissions: const [
{'type': 'setMode'}
]));
'rp',
AllowTool(
const {'x': 1},
updatedPermissions: const [
{'type': 'setMode'},
],
),
);
final decision = ((jsonDecode(proc.writes.single) as Map)['response'] as Map)['response'] as Map;
expect(decision['behavior'], 'allow');
expect(decision['updatedPermissions'], hasLength(1));
@@ -496,21 +489,24 @@ void main() {
});
test('resolving an AskUserQuestion leaves an answered echo in the log', () async {
proc.emit(jsonEncode({
'type': 'control_request',
'request_id': 'aq',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'AskUserQuestion',
'input': {'questions': <dynamic>[]},
},
}));
proc.emit(
jsonEncode({
'type': 'control_request',
'request_id': 'aq',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'AskUserQuestion',
'input': {'questions': <dynamic>[]},
},
}),
);
await Future<void>.delayed(Duration.zero);
session.resolvePrompt(
'aq',
AllowTool(const {
'answers': {'Pet': 'Dogs'}
}));
'aq',
AllowTool(const {
'answers': {'Pet': 'Dogs'},
}),
);
await Future<void>.delayed(Duration.zero);
final echo = items.whereType<UserMessage>().toList();
@@ -541,11 +537,13 @@ void main() {
});
test('an unsupported control_request is answered with an error (no hang)', () async {
proc.emit(jsonEncode({
'type': 'control_request',
'request_id': 'req-5',
'request': {'subtype': 'mystery_subtype'},
}));
proc.emit(
jsonEncode({
'type': 'control_request',
'request_id': 'req-5',
'request': {'subtype': 'mystery_subtype'},
}),
);
await Future<void>.delayed(Duration.zero);
expect(items, isEmpty);
@@ -646,19 +644,19 @@ void main() {
}
test('declares its sdkMcpServers in the initialize handshake', () {
final init = mproc.writes.map((w) => jsonDecode(w) as Map).firstWhere(
(m) => (m['request'] as Map?)?['subtype'] == 'initialize',
);
final init = mproc.writes.map((w) => jsonDecode(w) as Map).firstWhere((m) => (m['request'] as Map?)?['subtype'] == 'initialize');
expect((init['request'] as Map)['sdkMcpServers'], ['clide-team']);
});
test('answers mcp initialize with our serverInfo', () async {
mproc.emit(mcpMessage('m1', {
'method': 'initialize',
'params': {'protocolVersion': '2025-11-25'},
'jsonrpc': '2.0',
'id': 0,
}));
mproc.emit(
mcpMessage('m1', {
'method': 'initialize',
'params': {'protocolVersion': '2025-11-25'},
'jsonrpc': '2.0',
'id': 0,
}),
);
await Future<void>.delayed(Duration.zero);
final r = mcpResponseOf(mproc.writes.last);
expect((r['result'] as Map)['serverInfo'], {'name': 'clide-team', 'version': '9.9.9'});
@@ -673,12 +671,14 @@ void main() {
});
test('routes tools/call to the server and returns its result', () async {
mproc.emit(mcpMessage('m3', {
'method': 'tools/call',
'params': {'name': 'ping', 'arguments': <String, dynamic>{}},
'jsonrpc': '2.0',
'id': 2,
}));
mproc.emit(
mcpMessage('m3', {
'method': 'tools/call',
'params': {'name': 'ping', 'arguments': <String, dynamic>{}},
'jsonrpc': '2.0',
'id': 2,
}),
);
await Future<void>.delayed(Duration.zero);
expect(server.calls, ['ping']);
final r = mcpResponseOf(mproc.writes.last);
+5 -5
View File
@@ -19,7 +19,7 @@ void main() {
{'content': 'a', 'status': 'pending'},
{'content': 'b', 'status': 'in_progress'},
{'content': 'c', 'status': 'completed'},
])
]),
]);
expect(tasks, const [
TaskItem(text: 'a', status: TaskStatus.pending),
@@ -31,10 +31,10 @@ void main() {
test('the latest TodoWrite wins — a snapshot, not an append log', () {
final tasks = taskListFrom([
_todo([
{'content': 'old', 'status': 'pending'}
{'content': 'old', 'status': 'pending'},
], id: '1'),
_todo([
{'content': 'new', 'status': 'in_progress'}
{'content': 'new', 'status': 'in_progress'},
], id: '2'),
]);
expect(tasks, const [TaskItem(text: 'new', status: TaskStatus.inProgress)]);
@@ -45,7 +45,7 @@ void main() {
_todo([
{'activeForm': 'doing it', 'status': 'in_progress'},
{'status': 'weird'},
])
]),
]);
expect(tasks[0].text, 'doing it');
expect(tasks[1].text, '');
@@ -56,7 +56,7 @@ void main() {
expect(taskListFrom(const []), isEmpty);
expect(
taskListFrom([
AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'})
AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'}),
]),
isEmpty,
);
+62 -122
View File
@@ -18,7 +18,7 @@ void main() {
setUp(() async {
f = await KernelFixture.create();
broker = TeamBroker(deliver: (_, __) {});
broker = TeamBroker(deliver: (_, _) {});
broker.addMember(const TeamMemberRef(id: 'primary', name: 'lead', role: 'lead'));
broker.addMember(const TeamMemberRef(id: 'teammate:tyre', name: 'tyre', role: 'teammate'));
model = TeamChatModel(broker: broker);
@@ -36,17 +36,13 @@ void main() {
group('TeamChatSidebar', () {
Widget sidebar({VoidCallback? onPopOut}) => harness(
f,
SizedBox(
width: 220,
height: 400,
child: TeamChatSidebar(
model: model,
broker: broker,
onPopOut: onPopOut ?? () {},
),
),
);
f,
SizedBox(
width: 220,
height: 400,
child: TeamChatSidebar(model: model, broker: broker, onPopOut: onPopOut ?? () {}),
),
);
testWidgets('renders MESSAGES header', (tester) async {
await tester.pumpWidget(sidebar());
@@ -131,18 +127,16 @@ void main() {
var popped = false;
// Use a tall harness so the MESSAGES header (and its pop-out icon) is
// always in view and tappable.
await tester.pumpWidget(harness(
f,
SizedBox(
width: 300,
height: 800,
child: TeamChatSidebar(
model: model,
broker: broker,
onPopOut: () => popped = true,
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 300,
height: 800,
child: TeamChatSidebar(model: model, broker: broker, onPopOut: () => popped = true),
),
),
));
);
await tester.pump();
// The pop-out icon is wired via Semantics(label: 'Open full chat pane').
@@ -188,18 +182,13 @@ void main() {
await tester.pumpAndSettle();
// Focus the field and set text with a selection so cursor is at end.
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set value with explicit cursor position at end.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
await tester.pump();
@@ -211,18 +200,13 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// No match — _updateSuggestions called with null.
field.controller.value = const TextEditingValue(
text: '@zzz',
selection: TextSelection.collapsed(offset: 4),
);
field.controller.value = const TextEditingValue(text: '@zzz', selection: TextSelection.collapsed(offset: 4));
await tester.pump();
await tester.pump();
@@ -236,25 +220,17 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set @ty (matches tyre) → _showOverlay called.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
// Clear → _removeOverlay called.
field.controller.value = const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
field.controller.value = const TextEditingValue(text: '', selection: TextSelection.collapsed(offset: 0));
await tester.pump();
await tester.pump();
@@ -266,18 +242,13 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set a matching @-prefix to activate the overlay path.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
// Send Escape — _handleKeyEvent should return KeyEventResult.handled.
@@ -294,18 +265,13 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// A value with no selection (baseOffset == -1) triggers the cursor < 0 guard.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: -1),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: -1));
await tester.pump();
await tester.pump();
@@ -337,13 +303,13 @@ void main() {
group('TeamChatPane', () {
Widget pane() => harness(
f,
SizedBox(
width: 400,
height: 600,
child: TeamChatPane(model: model, broker: broker),
),
);
f,
SizedBox(
width: 400,
height: 600,
child: TeamChatPane(model: model, broker: broker),
),
);
testWidgets('renders Team Chat header', (tester) async {
await tester.pumpWidget(pane());
@@ -408,18 +374,13 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set @ty to activate overlay path.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
// Escape dismisses.
@@ -434,18 +395,13 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// @le → matches lead.
field.controller.value = const TextEditingValue(
text: '@le',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@le', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
await tester.pump();
@@ -457,17 +413,12 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
field.controller.value = const TextEditingValue(
text: '@zzz',
selection: TextSelection.collapsed(offset: 4),
);
field.controller.value = const TextEditingValue(text: '@zzz', selection: TextSelection.collapsed(offset: 4));
await tester.pump();
await tester.pump();
@@ -479,17 +430,12 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: -1),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: -1));
await tester.pump();
await tester.pump();
@@ -500,9 +446,7 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
await tester.enterText(chatField, ' ');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -519,9 +463,7 @@ void main() {
await tester.tap(interruptArea);
await tester.pump();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
await tester.enterText(chatField, 'urgent message');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -538,9 +480,7 @@ void main() {
await tester.tap(find.text('Interrupt'));
await tester.pump();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
await tester.enterText(chatField, '@lead do this now');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -549,26 +489,26 @@ void main() {
});
testWidgets('sidebar and pane share the same model (both surfaces update)', (tester) async {
await tester.pumpWidget(harness(
f,
SizedBox(
width: 800,
height: 600,
child: Row(
children: [
SizedBox(
width: 220,
child: TeamChatSidebar(
model: model,
broker: broker,
onPopOut: () {},
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 800,
height: 600,
child: Row(
children: [
SizedBox(
width: 220,
child: TeamChatSidebar(model: model, broker: broker, onPopOut: () {}),
),
),
Expanded(child: TeamChatPane(model: model, broker: broker)),
],
Expanded(
child: TeamChatPane(model: model, broker: broker),
),
],
),
),
),
));
);
await tester.pump();
// Posting from the model shows up in both surfaces.
@@ -16,15 +16,8 @@ import '../../helpers/widget_harness.dart';
Widget _lead() => const Center(child: Text('LEAD', textDirection: TextDirection.ltr));
TeamMemberJoined _joined(String name, String pane, {String? color}) => TeamMemberJoined(
team: 'myteam',
agentId: '$name@myteam',
name: name,
agentType: 'researcher',
paneId: pane,
model: 'sonnet',
color: color,
);
TeamMemberJoined _joined(String name, String pane, {String? color}) =>
TeamMemberJoined(team: 'myteam', agentId: '$name@myteam', name: name, agentType: 'researcher', paneId: pane, model: 'sonnet', color: color);
void main() {
late KernelFixture f;
+12 -2
View File
@@ -81,7 +81,12 @@ void main() {
test('already started: injects but does not move the status backwards (T-339)', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
final accepted = await applyTicketPickUp(payload(status: 'in_progress'), orchestrator: orch, ipc: ipc, messages: messages);
final accepted = await applyTicketPickUp(
payload(status: 'in_progress'),
orchestrator: orch,
ipc: ipc,
messages: messages,
);
await Future<void>.delayed(Duration.zero);
expect(accepted, isTrue); // prompt still delivered
@@ -91,7 +96,12 @@ void main() {
test('a backlog ticket is also startable', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
await applyTicketPickUp(payload(status: 'backlog'), orchestrator: orch, ipc: ipc, messages: messages);
await applyTicketPickUp(
payload(status: 'backlog'),
orchestrator: orch,
ipc: ipc,
messages: messages,
);
expect(statusCalls, hasLength(1));
});
@@ -12,29 +12,29 @@ import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:test/test.dart';
Map<String, dynamic> _userLine(String uuid, String text) => {
'type': 'user',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:06.708Z',
'message': {'role': 'user', 'content': text},
};
'type': 'user',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:06.708Z',
'message': {'role': 'user', 'content': text},
};
Map<String, dynamic> _asstLine(String uuid, String text) => {
'type': 'assistant',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:07.708Z',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text}
],
},
};
'type': 'assistant',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:07.708Z',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
};
void main() {
group('TranscriptPublisher', () {
@@ -49,9 +49,7 @@ void main() {
test('republishes reader items onto the bus (lead channel + item key)', tags: ['serial'], () async {
final dir = Directory('${base.path}/${workspace.replaceAll('/', '-')}');
await dir.create(recursive: true);
File('${dir.path}/session-abc.jsonl').writeAsStringSync(
'${[_userLine('u1', 'hello'), _asstLine('a1', 'hi there')].map(jsonEncode).join('\n')}\n',
);
File('${dir.path}/session-abc.jsonl').writeAsStringSync('${[_userLine('u1', 'hello'), _asstLine('a1', 'hi there')].map(jsonEncode).join('\n')}\n');
final bus = MessageBus();
addTearDown(bus.dispose);
@@ -59,11 +57,7 @@ void main() {
// Subscribe before the publisher starts the reader's first poll.
final sub = bus.subscribe(publisher: ClaudeConversation.publisher, channel: ClaudeConversation.leadChannel).listen(received.add);
final reader = TranscriptReader(
workspace,
projectsBase: base.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: base.path, pollInterval: const Duration(milliseconds: 20));
final pub = TranscriptPublisher(messages: bus, reader: reader);
await Future<void>.delayed(const Duration(milliseconds: 200));
@@ -87,12 +81,7 @@ void main() {
'coder@team-x',
const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000),
);
expect(full, {
'agentId': 'coder@team-x',
'model': 'claude-opus-4-7',
'permissionMode': 'plan',
'contextTokens': 21000,
});
expect(full, {'agentId': 'coder@team-x', 'model': 'claude-opus-4-7', 'permissionMode': 'plan', 'contextTokens': 21000});
// Absent fields are omitted (only agentId is always present).
expect(ClaudeConversation.memberStatusData('a', const SessionStatus()), {'agentId': 'a'});
});
+44 -210
View File
@@ -16,27 +16,18 @@ import 'package:test/test.dart';
/// Write [lines] to [file], each JSON-encoded, newline-terminated.
void writeLines(File file, List<Map<String, dynamic>> lines) {
file.writeAsStringSync(
'${lines.map(jsonEncode).join('\n')}\n',
mode: FileMode.writeOnly,
);
file.writeAsStringSync('${lines.map(jsonEncode).join('\n')}\n', mode: FileMode.writeOnly);
}
/// Append [lines] to [file].
void appendLines(File file, List<Map<String, dynamic>> lines) {
file.writeAsStringSync(
'${lines.map(jsonEncode).join('\n')}\n',
mode: FileMode.append,
);
file.writeAsStringSync('${lines.map(jsonEncode).join('\n')}\n', mode: FileMode.append);
}
/// Poll [ready] until it returns true or [timeout] elapses. Streaming
/// assertions use this instead of a fixed delay so they don't flake under
/// load (the reader polls on a timer and may parse off-isolate).
Future<void> pumpUntil(
bool Function() ready, {
Duration timeout = const Duration(seconds: 5),
}) async {
Future<void> pumpUntil(bool Function() ready, {Duration timeout = const Duration(seconds: 5)}) async {
final deadline = DateTime.now().add(timeout);
while (!ready() && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
@@ -53,24 +44,12 @@ Map<String, dynamic> envelope({
String version = '2.1.143',
String timestamp = '2026-05-16T08:53:06.708Z',
}) {
return {
'type': type,
'uuid': uuid,
'parentUuid': parentUuid,
'isSidechain': isSidechain,
'version': version,
'timestamp': timestamp,
if (message != null) 'message': message,
};
return {'type': type, 'uuid': uuid, 'parentUuid': parentUuid, 'isSidechain': isSidechain, 'version': version, 'timestamp': timestamp, 'message': ?message};
}
/// Build a `user` envelope whose content is a plain string.
Map<String, dynamic> userText(String uuid, String text) {
return envelope(
type: 'user',
uuid: uuid,
message: {'role': 'user', 'content': text},
);
return envelope(type: 'user', uuid: uuid, message: {'role': 'user', 'content': text});
}
/// Build a `user` envelope whose content is an array of text parts.
@@ -81,43 +60,28 @@ Map<String, dynamic> userTextArray(String uuid, List<String> parts) {
message: {
'role': 'user',
'content': [
for (final p in parts) {'type': 'text', 'text': p}
for (final p in parts) {'type': 'text', 'text': p},
],
},
);
}
/// Build a `user` envelope containing a tool_result.
Map<String, dynamic> userToolResult(
String uuid, {
required String toolUseId,
required String content,
bool isError = false,
}) {
Map<String, dynamic> userToolResult(String uuid, {required String toolUseId, required String content, bool isError = false}) {
return envelope(
type: 'user',
uuid: uuid,
message: {
'role': 'user',
'content': [
{
'type': 'tool_result',
'tool_use_id': toolUseId,
'content': content,
'is_error': isError,
}
{'type': 'tool_result', 'tool_use_id': toolUseId, 'content': content, 'is_error': isError},
],
},
);
}
/// Build an `assistant` envelope with a tool_use content block.
Map<String, dynamic> assistantToolUse(
String uuid, {
required String id,
required String name,
required Map<String, dynamic> input,
}) {
Map<String, dynamic> assistantToolUse(String uuid, {required String id, required String name, required Map<String, dynamic> input}) {
return envelope(
type: 'assistant',
uuid: uuid,
@@ -169,10 +133,7 @@ Map<String, dynamic> skipRecord(String type, String uuid) {
/// Parses [lines] synchronously via the real [TranscriptReader.parseLine] and
/// returns every emitted [ConversationItem].
List<ConversationItem> parseAll(
List<Map<String, dynamic>> lines, {
void Function(String)? onWarn,
}) {
List<ConversationItem> parseAll(List<Map<String, dynamic>> lines, {void Function(String)? onWarn}) {
final reader = TranscriptReader('/fake', onWarn: onWarn);
return [for (final l in lines) ...reader.parseLine(jsonEncode(l))];
}
@@ -185,12 +146,7 @@ void main() {
// -------------------------------------------------------------------------
group('ConversationItem model', () {
test('UserMessage holds text and metadata', () {
final item = UserMessage(
uuid: 'u1',
timestamp: _epoch,
isSidechain: false,
text: 'hello',
);
final item = UserMessage(uuid: 'u1', timestamp: _epoch, isSidechain: false, text: 'hello');
expect(item.uuid, 'u1');
expect(item.text, 'hello');
expect(item.isSidechain, isFalse);
@@ -198,48 +154,24 @@ void main() {
});
test('AssistantTextMessage holds text', () {
final item = AssistantTextMessage(
uuid: 'a1',
timestamp: _epoch,
isSidechain: false,
text: 'world',
);
final item = AssistantTextMessage(uuid: 'a1', timestamp: _epoch, isSidechain: false, text: 'world');
expect(item.text, 'world');
});
test('AssistantThinkingMessage holds thinking', () {
final item = AssistantThinkingMessage(
uuid: 't1',
timestamp: _epoch,
isSidechain: false,
thinking: 'pondering',
);
final item = AssistantThinkingMessage(uuid: 't1', timestamp: _epoch, isSidechain: false, thinking: 'pondering');
expect(item.thinking, 'pondering');
});
test('AssistantToolUse holds name and input', () {
final item = AssistantToolUse(
uuid: 'tu1',
timestamp: _epoch,
isSidechain: false,
toolUseId: 'toolu_001',
name: 'Bash',
input: const {'command': 'ls'},
);
final item = AssistantToolUse(uuid: 'tu1', timestamp: _epoch, isSidechain: false, toolUseId: 'toolu_001', name: 'Bash', input: const {'command': 'ls'});
expect(item.name, 'Bash');
expect(item.input['command'], 'ls');
expect(item.toString(), contains('Bash'));
});
test('ToolResultMessage holds content and error flag', () {
final item = ToolResultMessage(
uuid: 'r1',
timestamp: _epoch,
isSidechain: false,
toolUseId: 'toolu_001',
content: 'ok',
isError: false,
);
final item = ToolResultMessage(uuid: 'r1', timestamp: _epoch, isSidechain: false, toolUseId: 'toolu_001', content: 'ok', isError: false);
expect(item.content, 'ok');
expect(item.isError, isFalse);
});
@@ -264,14 +196,7 @@ void main() {
// -------------------------------------------------------------------------
group('TranscriptReader — parse: skip types', () {
test('skips attachment, system, last-prompt, permission-mode, file-history-snapshot, queue-operation', () {
final skipTypes = [
'attachment',
'system',
'last-prompt',
'permission-mode',
'file-history-snapshot',
'queue-operation',
];
final skipTypes = ['attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'];
for (final t in skipTypes) {
final items = parseAll([skipRecord(t, 'skip-$t')]);
expect(items, isEmpty, reason: 'type "$t" should be skipped');
@@ -290,7 +215,7 @@ void main() {
test('array content with text parts emits joined UserMessage', () {
final items = parseAll([
userTextArray('u2', ['foo', 'bar'])
userTextArray('u2', ['foo', 'bar']),
]);
expect(items, hasLength(1));
final msg = items.first as UserMessage;
@@ -298,9 +223,7 @@ void main() {
});
test('array content with tool_result emits ToolResultMessage', () {
final items = parseAll([
userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}'),
]);
final items = parseAll([userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}')]);
expect(items, hasLength(1));
final res = items.first as ToolResultMessage;
expect(res.toolUseId, 'toolu_abc');
@@ -309,9 +232,7 @@ void main() {
});
test('tool_result with is_error=true sets isError', () {
final items = parseAll([
userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true),
]);
final items = parseAll([userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true)]);
final res = items.first as ToolResultMessage;
expect(res.isError, isTrue);
});
@@ -324,12 +245,7 @@ void main() {
'role': 'user',
'content': [
{'type': 'text', 'text': 'see result'},
{
'type': 'tool_result',
'tool_use_id': 'toolu_mixed',
'content': 'done',
'is_error': false,
},
{'type': 'tool_result', 'tool_use_id': 'toolu_mixed', 'content': 'done', 'is_error': false},
],
},
);
@@ -352,23 +268,13 @@ void main() {
});
test('isSidechain flag is preserved', () {
final raw = envelope(
type: 'user',
uuid: 'u7',
isSidechain: true,
message: {'role': 'user', 'content': 'side'},
);
final raw = envelope(type: 'user', uuid: 'u7', isSidechain: true, message: {'role': 'user', 'content': 'side'});
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
});
test('parentUuid is parsed; empty parentUuid normalises to null (T-263)', () {
final withParent = envelope(
type: 'user',
uuid: 'u8',
parentUuid: 'msg-A',
message: {'role': 'user', 'content': 'a sidechain prompt'},
);
final withParent = envelope(type: 'user', uuid: 'u8', parentUuid: 'msg-A', message: {'role': 'user', 'content': 'a sidechain prompt'});
final withoutParent = envelope(
type: 'user',
uuid: 'u9',
@@ -382,22 +288,14 @@ void main() {
test('stream-json parent_tool_use_id marks a sidechain message (T-338)', () {
// The stream-json wire tags sub-agent messages with parent_tool_use_id and
// NO isSidechain flag — we must treat it as a sidechain item anyway.
final raw = envelope(
type: 'user',
uuid: 'sp',
message: {'role': 'user', 'content': 'go explore the codebase'},
)..['parent_tool_use_id'] = 'toolu_task1';
final raw = envelope(type: 'user', uuid: 'sp', message: {'role': 'user', 'content': 'go explore the codebase'})..['parent_tool_use_id'] = 'toolu_task1';
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
expect(items.first.parentToolUseId, 'toolu_task1');
});
test('empty parent_tool_use_id normalises to null and stays main-thread (T-338)', () {
final raw = envelope(
type: 'user',
uuid: 'mt',
message: {'role': 'user', 'content': 'a normal turn'},
)..['parent_tool_use_id'] = '';
final raw = envelope(type: 'user', uuid: 'mt', message: {'role': 'user', 'content': 'a normal turn'})..['parent_tool_use_id'] = '';
final items = parseAll([raw]);
expect(items.first.isSidechain, isFalse);
expect(items.first.parentToolUseId, isNull);
@@ -436,12 +334,7 @@ void main() {
test('tool_use block emits AssistantToolUse', () {
final items = parseAll([
assistantToolUse(
'a4',
id: 'toolu_001',
name: 'Bash',
input: {'command': 'ls -la', 'description': 'List files'},
),
assistantToolUse('a4', id: 'toolu_001', name: 'Bash', input: {'command': 'ls -la', 'description': 'List files'}),
]);
expect(items, hasLength(1));
final tu = items.first as AssistantToolUse;
@@ -463,7 +356,7 @@ void main() {
'type': 'tool_use',
'id': 'toolu_002',
'name': 'Read',
'input': {'file_path': '/foo'}
'input': {'file_path': '/foo'},
},
],
},
@@ -519,21 +412,13 @@ void main() {
group('TranscriptReader — version drift-guard', () {
test('known versions (1.x, 2.x) produce no warning', () {
final warnings = <String>[];
parseAll(
[userText('u1', 'hello'), assistantText('a1', 'world')],
onWarn: warnings.add,
);
parseAll([userText('u1', 'hello'), assistantText('a1', 'world')], onWarn: warnings.add);
expect(warnings, isEmpty);
});
test('unknown major version warns and still emits parseable items', () {
final warnings = <String>[];
final raw = envelope(
type: 'user',
uuid: 'u1',
version: '99.0.1',
message: {'role': 'user', 'content': 'future format'},
);
final raw = envelope(type: 'user', uuid: 'u1', version: '99.0.1', message: {'role': 'user', 'content': 'future format'});
final items = parseAll([raw], onWarn: warnings.add);
expect(warnings, hasLength(1));
expect(warnings.first, contains('99'));
@@ -567,10 +452,7 @@ void main() {
});
test('malformed JSON line is skipped without throwing', () {
expect(
TranscriptReader('/fake').parseLine('not json!!!'),
isEmpty,
);
expect(TranscriptReader('/fake').parseLine('not json!!!'), isEmpty);
});
});
@@ -588,14 +470,9 @@ void main() {
'role': 'assistant',
'model': 'claude-opus-4-7',
'content': [
{'type': 'text', 'text': 'hi'}
{'type': 'text', 'text': 'hi'},
],
'usage': {
'input_tokens': 2,
'cache_read_input_tokens': 1000,
'cache_creation_input_tokens': 500,
'output_tokens': 99,
},
'usage': {'input_tokens': 2, 'cache_read_input_tokens': 1000, 'cache_creation_input_tokens': 500, 'output_tokens': 99},
},
}),
].join('\n');
@@ -672,16 +549,9 @@ void main() {
await projectDir.create(recursive: true);
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
writeLines(sessionFile, [
userText('u1', 'first message'),
assistantText('a1', 'first reply'),
]);
writeLines(sessionFile, [userText('u1', 'first message'), assistantText('a1', 'first reply')]);
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -701,11 +571,7 @@ void main() {
writeLines(sessionFile, [userText('u1', 'initial')]);
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -740,11 +606,7 @@ void main() {
writeLines(sessionFile, [userText('u1', 'old session')]);
await sessionFile.setLastModified(DateTime.utc(2020));
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -778,11 +640,7 @@ void main() {
skipRecord('system', 'skip3'),
]);
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -802,18 +660,11 @@ void main() {
// A long pre-existing transcript — the kind that froze the UI when
// parsed in full on attach.
writeLines(sessionFile, [
for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i'),
]);
writeLines(sessionFile, [for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i')]);
// Cap the initial read well below the file size so only the last
// records are within the tail window.
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
initialTailBytes: 256,
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), initialTailBytes: 256);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -826,10 +677,7 @@ void main() {
expect(collected, isNotEmpty);
expect(collected.length, lessThan(200));
// The most recent record is always intact at the end of the file.
expect(
collected.whereType<AssistantTextMessage>().last.text,
'reply number 199',
);
expect(collected.whereType<AssistantTextMessage>().last.text, 'reply number 199');
});
test('explicit file: tails that exact file, ignoring newest-discovery', () async {
@@ -840,12 +688,7 @@ void main() {
final target = File('${dir.path}/agent-abc.jsonl');
writeLines(target, [assistantText('a1', 'from the explicit file')]);
final reader = TranscriptReader(
'/unused/workspace',
projectsBase: '/nonexistent',
pollInterval: const Duration(milliseconds: 20),
file: target.path,
);
final reader = TranscriptReader('/unused/workspace', projectsBase: '/nonexistent', pollInterval: const Duration(milliseconds: 20), file: target.path);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -864,12 +707,7 @@ void main() {
addTearDown(() => dir.delete(recursive: true));
final target = File('${dir.path}/agent-late.jsonl');
final reader = TranscriptReader(
'/unused',
projectsBase: '/nonexistent',
pollInterval: const Duration(milliseconds: 20),
file: target.path,
);
final reader = TranscriptReader('/unused', projectsBase: '/nonexistent', pollInterval: const Duration(milliseconds: 20), file: target.path);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -901,7 +739,7 @@ void main() {
'role': 'assistant',
'model': 'claude-sonnet-4-6',
'content': [
{'type': 'text', 'text': 'hi'}
{'type': 'text', 'text': 'hi'},
],
'usage': {'input_tokens': 5, 'cache_read_input_tokens': 200, 'cache_creation_input_tokens': 0, 'output_tokens': 10},
},
@@ -955,18 +793,14 @@ List<Map<String, dynamic>> _snapshotFixture() {
'type': 'tool_use',
'id': toolUseId,
'name': 'Bash',
'input': {'command': 'ls -la'}
'input': {'command': 'ls -la'},
},
],
},
),
// tool result (user turn)
userToolResult(
'u-002',
toolUseId: toolUseId,
content: 'total 4\n-rw-r--r-- file.dart',
),
userToolResult('u-002', toolUseId: toolUseId, content: 'total 4\n-rw-r--r-- file.dart'),
// assistant text reply
assistantText('a-002', 'The directory contains file.dart.'),
@@ -47,22 +47,26 @@ void main() {
});
test('contributes the install command', () async {
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/bin'},
bundledClientCandidates: const [],
installDir: '${tmp.path}/bin',
));
await boot(
CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/bin'},
bundledClientCandidates: const [],
installDir: '${tmp.path}/bin',
),
);
expect(f.services.commands.get('clide.installCli'), isNotNull);
});
test('warns on activation when clide is missing from PATH', () async {
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/empty'},
bundledClientCandidates: const [],
installDir: '${tmp.path}/bin',
));
await boot(
CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/empty'},
bundledClientCandidates: const [],
installDir: '${tmp.path}/bin',
),
);
final notes = f.services.notify.active;
expect(notes, isNotEmpty);
expect(notes.first.level, NotificationLevel.warning);
@@ -73,12 +77,7 @@ void main() {
final gui = touchExec('${tmp.path}/gui/clide').path;
final binDir = Directory('${tmp.path}/bin')..createSync();
Link('${binDir.path}/clide').createSync(gui); // PATH clide → the GUI
await boot(CliInstaller(
resolvedExecutable: gui,
env: {'PATH': binDir.path},
bundledClientCandidates: const [],
installDir: binDir.path,
));
await boot(CliInstaller(resolvedExecutable: gui, env: {'PATH': binDir.path}, bundledClientCandidates: const [], installDir: binDir.path));
final notes = f.services.notify.active;
expect(notes, isNotEmpty);
expect(notes.first.level, NotificationLevel.warning);
@@ -89,12 +88,9 @@ void main() {
final dev = touchExec('${tmp.path}/native/linux-x64/clide').path;
final binDir = Directory('${tmp.path}/bin')..createSync();
Link('${binDir.path}/clide').createSync(dev);
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir.path},
bundledClientCandidates: const [],
installDir: binDir.path,
));
await boot(
CliInstaller(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir.path}, bundledClientCandidates: const [], installDir: binDir.path),
);
final notes = f.services.notify.active;
expect(notes, isNotEmpty);
expect(notes.first.level, NotificationLevel.info);
@@ -104,47 +100,35 @@ void main() {
test('does not warn when clide is already installed', () async {
final binDir = Directory('${tmp.path}/bin')..createSync();
touchExec('${binDir.path}/clide');
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir.path},
bundledClientCandidates: const [],
installDir: binDir.path,
));
await boot(
CliInstaller(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir.path}, bundledClientCandidates: const [], installDir: binDir.path),
);
expect(f.services.notify.active, isEmpty);
});
test('running the command installs the client and reports success', () async {
final src = touchExec('${tmp.path}/bundle/clide-cli');
final binDir = '${tmp.path}/bin';
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir},
bundledClientCandidates: [src.path],
installDir: binDir,
));
await boot(CliInstaller(resolvedExecutable: '${tmp.path}/gui/clide', env: {'PATH': binDir}, bundledClientCandidates: [src.path], installDir: binDir));
final r = await f.services.commands.execute('clide.installCli');
expect(r.ok, isTrue);
expect(r.data['installed'], '$binDir/clide');
expect(File('$binDir/clide').existsSync(), isTrue);
expect(
f.services.notify.active.any((n) => n.level == NotificationLevel.success && n.title == 'clide CLI installed'),
isTrue,
);
expect(f.services.notify.active.any((n) => n.level == NotificationLevel.success && n.title == 'clide CLI installed'), isTrue);
});
test('running the command surfaces a tool error when nothing to install', () async {
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/bin'},
bundledClientCandidates: const [],
installDir: '${tmp.path}/bin',
));
await boot(
CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/bin'},
bundledClientCandidates: const [],
installDir: '${tmp.path}/bin',
),
);
final r = await f.services.commands.execute('clide.installCli');
expect(r.ok, isFalse);
expect(r.error!.kind, IpcErrorKind.toolError);
expect(
f.services.notify.active.any((n) => n.level == NotificationLevel.error),
isTrue,
);
expect(f.services.notify.active.any((n) => n.level == NotificationLevel.error), isTrue);
});
}
+165 -247
View File
@@ -30,12 +30,7 @@ import '../../helpers/widget_harness.dart';
/// [Slots.contextPanel] slot into the arrangement so that
/// [setVisible]/[setCollapsed] have a state entry to mutate.
Future<void> _bootExtension(KernelFixture f) async {
f.services.panels.registerSlot(
const SlotDefinition(
id: Slots.contextPanel,
position: SlotPosition.right,
),
);
f.services.panels.registerSlot(const SlotDefinition(id: Slots.contextPanel, position: SlotPosition.right));
// Seed the ARRANGEMENT with the context-panel slot (visible:false) so the
// extension's setVisible/setCollapsed reveal actually round-trips — in
// production the default-layout preset does this; LayoutArrangement.setVisible
@@ -44,9 +39,7 @@ Future<void> _bootExtension(KernelFixture f) async {
const LayoutPresetContribution(
id: 'test.preset',
displayName: 'test',
slots: [
LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: false),
],
slots: [LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: false)],
),
);
f.services.extensions.register(DecisionsExtension());
@@ -63,19 +56,19 @@ void _select(KernelFixture f, String id) {
// ---------------------------------------------------------------------------
IpcResponse _decisionResponse(String id, {String? filePath}) => IpcResponse.ok(
id: '',
data: {
'id': id,
'title': 'Decision $id',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': 'Body of $id.',
'refs': <Object?>[],
'file_path': filePath ?? 'governance/decisions/architecture.md',
},
);
id: '',
data: {
'id': id,
'title': 'Decision $id',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': 'Body of $id.',
'refs': <Object?>[],
'file_path': filePath ?? 'governance/decisions/architecture.md',
},
);
// ---------------------------------------------------------------------------
// Extension-level unit tests (no Flutter widgets, no IPC)
@@ -181,17 +174,15 @@ void main() {
// After deactivation contributions are removed, so no decisions.detail
// tab at all — but the panel activation path must not fire either.
f.services.panels.registerSlot(
const SlotDefinition(
id: Slots.contextPanel,
position: SlotPosition.right,
),
);
f.services.panels.registerSlot(const SlotDefinition(id: Slots.contextPanel, position: SlotPosition.right));
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-99'});
await Future<void>.delayed(Duration.zero);
expect(f.services.panels.activeTabIn(Slots.contextPanel), isNot('decisions.detail'),
reason: 'deactivated extension must not respond to selection messages');
expect(
f.services.panels.activeTabIn(Slots.contextPanel),
isNot('decisions.detail'),
reason: 'deactivated extension must not respond to selection messages',
);
});
});
@@ -219,9 +210,7 @@ void main() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
harness(f, DecisionDetailView(initialId: initialId)),
);
await tester.pumpWidget(harness(f, DecisionDetailView(initialId: initialId)));
await pumpAsync(tester);
}
@@ -334,15 +323,12 @@ void main() {
testWidgets('IPC error leaves _decision null — shows placeholder', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(_) async => IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'read failed',
),
));
'pql.decisions.read',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'read failed'),
),
);
await pumpView(tester, initialId: 'D-99');
@@ -352,21 +338,22 @@ void main() {
testWidgets('decision with status open shows status badge', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'Q-1',
'title': 'Open question',
'type': 'question',
'domain': 'architecture',
'status': 'open',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/questions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'Q-1',
'title': 'Open question',
'type': 'question',
'domain': 'architecture',
'status': 'open',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/questions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'Q-1');
@@ -375,21 +362,22 @@ void main() {
testWidgets('decision with status resolved shows status badge', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'Q-2',
'title': 'Resolved question',
'type': 'question',
'domain': 'architecture',
'status': 'resolved',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/questions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'Q-2',
'title': 'Resolved question',
'type': 'question',
'domain': 'architecture',
'status': 'resolved',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/questions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'Q-2');
@@ -398,21 +386,22 @@ void main() {
testWidgets('decision with unknown status shows status badge in muted color', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-20',
'title': 'Deprecated decision',
'type': 'confirmed',
'domain': 'architecture',
'status': 'deprecated',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-20',
'title': 'Deprecated decision',
'type': 'confirmed',
'domain': 'architecture',
'status': 'deprecated',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'D-20');
@@ -421,23 +410,24 @@ void main() {
testWidgets('decision with refs using source_id renders ref card', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-30',
'title': 'Decision with source ref',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': '',
'refs': [
{'source_id': 'D-5', 'ref_type': 'amends'},
],
'file_path': 'governance/decisions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-30',
'title': 'Decision with source ref',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': '',
'refs': [
{'source_id': 'D-5', 'ref_type': 'amends'},
],
'file_path': 'governance/decisions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'D-30');
@@ -450,23 +440,24 @@ void main() {
// the T-prefix routing in _navigateToRecord is only reachable via
// ClideMarkdown.onRecordTap (markdown body links).
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-40',
'title': 'Decision with ticket ref',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': '',
'refs': [
{'target_id': 'T-123', 'ref_type': 'tracked-by'},
],
'file_path': 'governance/decisions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-40',
'title': 'Decision with ticket ref',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': '',
'refs': [
{'target_id': 'T-123', 'ref_type': 'tracked-by'},
],
'file_path': 'governance/decisions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'D-40');
expect(find.text('T-123'), findsOneWidget);
@@ -484,21 +475,22 @@ void main() {
testWidgets('decision with non-empty body renders body section', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-50',
'title': 'Decision with body',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-15',
'body': 'This is the decision body text.',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-50',
'title': 'Decision with body',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-15',
'body': 'This is the decision body text.',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'D-50');
@@ -509,20 +501,21 @@ void main() {
testWidgets('decision without date omits date row', (tester) async {
f.ipc.stub(
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-60',
'title': 'No date decision',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
));
'pql.decisions.read',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'D-60',
'title': 'No date decision',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
),
);
await pumpView(tester, initialId: 'D-60');
@@ -570,24 +563,14 @@ void main() {
testWidgets('back disabled on initial load', (tester) async {
await pumpView(tester, initialId: 'D-1');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false,
),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false), findsOneWidget);
});
testWidgets('back enabled after two selections', (tester) async {
await pumpView(tester, initialId: 'D-1');
await open(tester, 'D-2');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
),
findsWidgets,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true)), findsWidgets);
});
testWidgets('back navigates to previous decision', (tester) async {
@@ -596,9 +579,7 @@ void main() {
// Title appears in pane header subtitle + body card.
expect(find.text('Decision D-2'), findsWidgets);
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backBtn = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backBtn.first);
await pumpAsync(tester);
@@ -614,9 +595,7 @@ void main() {
addTearDown(sub.cancel);
// Go back — re-emits on 'load', NOT 'selection'.
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backBtn = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backBtn.first);
await pumpAsync(tester);
@@ -627,12 +606,7 @@ void main() {
await pumpView(tester, initialId: 'D-1');
await open(tester, 'D-2');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false), findsOneWidget);
});
testWidgets('forward navigates after back', (tester) async {
@@ -640,17 +614,13 @@ void main() {
await open(tester, 'D-2');
// Go back to D-1.
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backBtn = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backBtn.first);
await pumpAsync(tester);
expect(find.text('Decision D-1'), findsWidgets);
// Go forward to D-2.
final fwdBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
);
final fwdBtn = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true));
await tester.tap(fwdBtn.first);
await pumpAsync(tester);
expect(find.text('Decision D-2'), findsWidgets);
@@ -661,21 +631,14 @@ void main() {
await open(tester, 'D-2');
// Go back to D-1.
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backBtn = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backBtn.first);
await pumpAsync(tester);
// Open D-3 — truncates D-2 forward history.
await open(tester, 'D-3');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false), findsOneWidget);
});
});
@@ -714,37 +677,23 @@ void main() {
testWidgets('pin jump affordance not visible before pin set', (tester) async {
await pumpView(tester, initialId: 'D-1');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsNothing);
});
testWidgets('pin current shows jump-to-pin affordance', (tester) async {
await pumpView(tester, initialId: 'D-1');
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first);
await pumpAsync(tester);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsOneWidget);
});
testWidgets('jump to pin loads the pinned decision', (tester) async {
await pumpView(tester, initialId: 'D-1');
// Pin D-1.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first);
await pumpAsync(tester);
// Navigate to D-2.
@@ -753,11 +702,7 @@ void main() {
expect(find.text('Decision D-2'), findsWidgets);
// Jump to pin.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Jump to pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin').first);
await pumpAsync(tester);
expect(find.text('Decision D-1'), findsWidgets);
@@ -767,28 +712,14 @@ void main() {
await pumpView(tester, initialId: 'D-1');
// Pin D-1 → the jump-to-pin button appears in the navigator.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first);
await pumpAsync(tester);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsOneWidget);
// Tapping the toggle again (now 'Unpin') clears the pin.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Unpin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Unpin').first);
await pumpAsync(tester);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsNothing);
});
});
@@ -818,10 +749,7 @@ void main() {
testWidgets('edit pencil not visible when no decision loaded', (tester) async {
await pumpView(tester);
// Placeholder state — no chrome.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'), findsNothing);
});
testWidgets('edit pencil fires editor.open with file_path from decision', (tester) async {
@@ -838,16 +766,9 @@ void main() {
await pumpView(tester, initialId: 'D-1');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'), findsOneWidget);
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Edit in editor',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor').first);
await pumpAsync(tester);
expect(editorOpenArgs, hasLength(1));
@@ -874,10 +795,7 @@ void main() {
await pumpView(tester, initialId: 'D-70');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'), findsNothing);
});
});
}
+32 -99
View File
@@ -26,28 +26,17 @@ import '../../helpers/widget_harness.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
IpcResponse _err(String msg) => IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: msg,
),
);
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
);
Map<String, Object?> _decision({
required String id,
required String title,
String type = 'confirmed',
String domain = 'architecture',
String? status,
}) =>
{
'id': id,
'title': title,
'type': type,
'domain': domain,
if (status != null) 'status': status,
};
Map<String, Object?> _decision({required String id, required String title, String type = 'confirmed', String domain = 'architecture', String? status}) => {
'id': id,
'title': title,
'type': type,
'domain': domain,
'status': ?status,
};
/// Register both sync and list stubs, returning the provided list of decisions.
void _stubDecisions(KernelFixture f, List<Map<String, Object?>> decisions) {
@@ -122,11 +111,7 @@ void main() {
'pql.decisions.list',
(_) async => IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'failed to load decisions',
),
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'failed to load decisions'),
),
);
@@ -155,9 +140,7 @@ void main() {
group('DecisionsView — grouping into sections', () {
testWidgets('confirmed decisions appear in CONFIRMED accordion', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'Architecture choice'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'Architecture choice')]);
await pumpView(tester);
// Accordion label renders as 'CONFIRMED · N'.
@@ -168,9 +151,7 @@ void main() {
});
testWidgets('question decisions appear in QUESTIONS accordion header', (tester) async {
_stubDecisions(f, [
_decision(id: 'Q-1', title: 'Open question', type: 'question', domain: 'tooling'),
]);
_stubDecisions(f, [_decision(id: 'Q-1', title: 'Open question', type: 'question', domain: 'tooling')]);
await pumpView(tester);
// The header always renders even when the section is collapsed.
@@ -183,9 +164,7 @@ void main() {
});
testWidgets('rejected decisions appear in REJECTED accordion header', (tester) async {
_stubDecisions(f, [
_decision(id: 'R-1', title: 'Rejected idea', type: 'rejected', domain: 'ui'),
]);
_stubDecisions(f, [_decision(id: 'R-1', title: 'Rejected idea', type: 'rejected', domain: 'ui')]);
await pumpView(tester);
expect(find.textContaining('REJECTED'), findsOneWidget);
@@ -212,9 +191,7 @@ void main() {
testWidgets('card with resolved status shows resolved badge (via filter)', (tester) async {
// Use a confirmed decision with resolved status so the card is
// visible without needing to expand the section manually.
_stubDecisions(f, [
_decision(id: 'D-2', title: 'Resolved decision', type: 'confirmed', domain: 'arch', status: 'resolved'),
]);
_stubDecisions(f, [_decision(id: 'D-2', title: 'Resolved decision', type: 'confirmed', domain: 'arch', status: 'resolved')]);
await pumpView(tester);
// CONFIRMED is pinned-expanded; card is visible.
@@ -222,9 +199,7 @@ void main() {
});
testWidgets('domain label is shown on card', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-5', title: 'Some D', domain: 'architecture'),
]);
_stubDecisions(f, [_decision(id: 'D-5', title: 'Some D', domain: 'architecture')]);
await pumpView(tester);
expect(find.text('architecture'), findsOneWidget);
@@ -233,9 +208,7 @@ void main() {
group('DecisionsView — card tap publishes selection', () {
testWidgets('tapping a confirmed card publishes builtin.decisions/selection', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-3', title: 'Click me'),
]);
_stubDecisions(f, [_decision(id: 'D-3', title: 'Click me')]);
await pumpView(tester);
final received = <Message>[];
@@ -250,9 +223,7 @@ void main() {
});
testWidgets('tapping a question card publishes the correct id', (tester) async {
_stubDecisions(f, [
_decision(id: 'Q-4', title: 'Q card', type: 'question', domain: 'tooling'),
]);
_stubDecisions(f, [_decision(id: 'Q-4', title: 'Q card', type: 'question', domain: 'tooling')]);
await pumpView(tester);
// QUESTIONS section is collapsed by default — tap header to expand it.
@@ -273,10 +244,7 @@ void main() {
group('DecisionsView — filter', () {
testWidgets('filter box narrows results by id', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'Architecture choice'),
_decision(id: 'D-2', title: 'Build tool selection'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'Architecture choice'), _decision(id: 'D-2', title: 'Build tool selection')]);
await pumpView(tester);
expect(find.text('D-1'), findsOneWidget);
@@ -295,10 +263,7 @@ void main() {
});
testWidgets('filter box narrows results by title', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'Architecture choice'),
_decision(id: 'D-2', title: 'Build tool selection'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'Architecture choice'), _decision(id: 'D-2', title: 'Build tool selection')]);
await pumpView(tester);
final filterBox = find.byWidgetPredicate((w) => w is EditableText);
@@ -311,10 +276,7 @@ void main() {
});
testWidgets('filter by domain shows matching card', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'Arch', domain: 'architecture'),
_decision(id: 'D-2', title: 'Tool', domain: 'tooling'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'Arch', domain: 'architecture'), _decision(id: 'D-2', title: 'Tool', domain: 'tooling')]);
await pumpView(tester);
final filterBox = find.byWidgetPredicate((w) => w is EditableText);
@@ -327,9 +289,7 @@ void main() {
});
testWidgets('filter with no matches yields empty sections', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'Something'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'Something')]);
await pumpView(tester);
final filterBox = find.byWidgetPredicate((w) => w is EditableText);
@@ -344,9 +304,7 @@ void main() {
group('DecisionsView — section toggle', () {
testWidgets('toggling confirmed section removes confirmed from pinned', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'Arch choice'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'Arch choice')]);
await pumpView(tester);
// CONFIRMED is pinned by default and should be expanded (D-1 visible).
@@ -362,9 +320,7 @@ void main() {
});
testWidgets('toggling a non-pinned section expands it', (tester) async {
_stubDecisions(f, [
_decision(id: 'R-1', title: 'Rejected one', type: 'rejected', domain: 'ui'),
]);
_stubDecisions(f, [_decision(id: 'R-1', title: 'Rejected one', type: 'rejected', domain: 'ui')]);
await pumpView(tester);
// REJECTED starts unexpanded (not in _pinned).
@@ -381,10 +337,7 @@ void main() {
group('DecisionsView — focus message', () {
testWidgets('receiving a focus message updates _focusedId and rebuilds', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'First'),
_decision(id: 'D-2', title: 'Second'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'First'), _decision(id: 'D-2', title: 'Second')]);
await pumpView(tester);
// Both cards visible.
@@ -400,9 +353,7 @@ void main() {
});
testWidgets('focus message with null id is ignored', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'First'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'First')]);
await pumpView(tester);
f.services.messages.publish('builtin.decisions', 'focus', {'id': null});
@@ -412,9 +363,7 @@ void main() {
});
testWidgets('focus message with same id as already focused is ignored', (tester) async {
_stubDecisions(f, [
_decision(id: 'D-1', title: 'First'),
]);
_stubDecisions(f, [_decision(id: 'D-1', title: 'First')]);
await pumpView(tester);
f.services.messages.publish('builtin.decisions', 'focus', {'id': 'D-1'});
@@ -435,9 +384,7 @@ void main() {
f.ipc.stub('pql.decisions.list', (_) async {
listCallCount++;
return _ok({
'decisions': [
_decision(id: 'D-$listCallCount', title: 'Call $listCallCount'),
],
'decisions': [_decision(id: 'D-$listCallCount', title: 'Call $listCallCount')],
});
});
@@ -446,9 +393,7 @@ void main() {
// Tap the refresh icon button — it has tooltip 'Refresh decisions'.
// Find ClideTappable widgets and tap the one with the refresh tooltip.
final refreshTappable = find.byWidgetPredicate(
(w) => w is ClideTappable && w.tooltip == 'Refresh decisions',
);
final refreshTappable = find.byWidgetPredicate((w) => w is ClideTappable && w.tooltip == 'Refresh decisions');
await tester.tap(refreshTappable);
await pumpAsync(tester);
@@ -463,9 +408,7 @@ void main() {
f.ipc.stub('pql.decisions.list', (_) async {
listCallCount++;
return _ok({
'decisions': [
_decision(id: 'D-$listCallCount', title: 'Version $listCallCount'),
],
'decisions': [_decision(id: 'D-$listCallCount', title: 'Version $listCallCount')],
});
});
@@ -473,12 +416,7 @@ void main() {
expect(listCallCount, 1);
// Emit a files.changed event for a decisions path.
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': 'decisions/architecture.md'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'decisions/architecture.md'}, ts: DateTime.now().toUtc()));
await pumpAsync(tester);
expect(listCallCount, greaterThanOrEqualTo(2));
@@ -495,12 +433,7 @@ void main() {
await pumpView(tester);
final countAfterLoad = listCallCount;
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': 'lib/main.dart'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'lib/main.dart'}, ts: DateTime.now().toUtc()));
await pumpAsync(tester);
// Count must not have incremented.
@@ -37,13 +37,16 @@ void main() {
// Real layering: vim.yaml preset (low) under a contributions layer
// carrying the extension's actual focusMode.exit escape binding.
final preset = KeymapLayer.fromYaml(File('assets/keymaps/vim.yaml').readAsStringSync());
final contributions = KeymapLayer(name: 'contributions', bindings: [
KeymapBinding.chord(
KeyChord.parse(exitCmd.defaultBinding!),
intent: InvokeCommandIntent(exitCmd.command),
when: WhenExpr.tryParse(exitCmd.bindingWhen),
),
]);
final contributions = KeymapLayer(
name: 'contributions',
bindings: [
KeymapBinding.chord(
KeyChord.parse(exitCmd.defaultBinding!),
intent: InvokeCommandIntent(exitCmd.command),
when: WhenExpr.tryParse(exitCmd.bindingWhen),
),
],
);
// Keymap flattens layers in reverse, so contributions outrank preset —
// matching KeymapService._rebuildActive's ordering.
km = Keymap([preset, contributions]);
+1 -4
View File
@@ -40,10 +40,7 @@ void main() {
test('declares a layout preset contribution', () {
final ext = DefaultLayoutExtension();
expect(
ext.contributions.whereType<LayoutPresetContribution>(),
hasLength(1),
);
expect(ext.contributions.whereType<LayoutPresetContribution>(), hasLength(1));
});
test('all commands return not-activated errors before activate', () async {
+7 -1
View File
@@ -81,7 +81,13 @@ void main() {
});
test('a git.diff error surfaces on the controller', () async {
ipc.stub('git.diff', (_) async => IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom')));
ipc.stub(
'git.diff',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
),
);
await c.load();
expect(c.error, 'boom');
});
+39 -35
View File
@@ -19,22 +19,22 @@ Map<String, Object?> _file(String path) => {'path': path, 'hunks': const []};
/// A file with rename metadata and a hunk carrying one of each line kind, so
/// the view exercises `_FileDiff` meta, `_HunkView`, and `_DiffLineRow`.
Map<String, Object?> _richFile() => {
'path': 'lib/c.dart',
'renamed': true,
'oldPath': 'lib/old.dart',
'additions': 2,
'removals': 1,
'hunks': [
{
'header': '@@ -1,2 +1,3 @@',
'lines': [
{'kind': 'context', 'text': 'kept', 'oldLineNo': 1, 'newLineNo': 1},
{'kind': 'addition', 'text': 'new line', 'newLineNo': 2},
{'kind': 'removal', 'text': 'gone line', 'oldLineNo': 2},
],
},
'path': 'lib/c.dart',
'renamed': true,
'oldPath': 'lib/old.dart',
'additions': 2,
'removals': 1,
'hunks': [
{
'header': '@@ -1,2 +1,3 @@',
'lines': [
{'kind': 'context', 'text': 'kept', 'oldLineNo': 1, 'newLineNo': 1},
{'kind': 'addition', 'text': 'new line', 'newLineNo': 2},
{'kind': 'removal', 'text': 'gone line', 'oldLineNo': 2},
],
};
},
],
};
bool _textIs(Object? w, String s) => w is ClideText && w.data == s;
bool _textHas(Object? w, String s) => w is ClideText && w.data.contains(s);
@@ -52,10 +52,11 @@ void main() {
bus = DaemonBus();
ipc = FakeDaemonClient(log: Logger(), events: bus);
ipc.stub(
'git.diff',
(_) async => _ok({
'diffs': [_file('lib/a.dart'), _file('lib/b.dart')]
}));
'git.diff',
(_) async => _ok({
'diffs': [_file('lib/a.dart'), _file('lib/b.dart')],
}),
);
c = DiffController(ipc: ipc, events: bus);
});
@@ -94,10 +95,11 @@ void main() {
testWidgets('renders hunk header, each diff line kind, and rename meta', (tester) async {
ipc.stub(
'git.diff',
(_) async => _ok({
'diffs': [_richFile()]
}));
'git.diff',
(_) async => _ok({
'diffs': [_richFile()],
}),
);
await c.load();
await tester.pumpWidget(harness(f, DiffView(controller: c)));
await tester.pumpAndSettle();
@@ -111,13 +113,14 @@ void main() {
testWidgets('renders new/deleted/binary metadata and skips hunks for binary', (tester) async {
ipc.stub(
'git.diff',
(_) async => _ok({
'diffs': [
{'path': 'img.png', 'new': true, 'binary': true, 'hunks': const []},
{'path': 'gone.txt', 'deleted': true, 'hunks': const []},
]
}));
'git.diff',
(_) async => _ok({
'diffs': [
{'path': 'img.png', 'new': true, 'binary': true, 'hunks': const []},
{'path': 'gone.txt', 'deleted': true, 'hunks': const []},
],
}),
);
await c.load();
await tester.pumpWidget(harness(f, DiffView(controller: c)));
await tester.pumpAndSettle();
@@ -129,11 +132,12 @@ void main() {
testWidgets('renders the error message when git.diff fails', (tester) async {
ipc.stub(
'git.diff',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
));
'git.diff',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
),
);
await c.load();
await tester.pumpWidget(harness(f, DiffView(controller: c)));
await tester.pumpAndSettle();
+151 -122
View File
@@ -15,12 +15,12 @@ IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data)
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
Map<String, Object?> _read(String id, String path, String content, {bool dirty = false}) => {
'id': id,
'path': path,
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': dirty,
};
'id': id,
'path': path,
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': dirty,
};
void emitEditor(DaemonBus bus, String kind, Map<String, Object?> data) {
bus.emit(DaemonEvent(subsystem: 'editor', kind: kind, data: data, ts: DateTime.now().toUtc()));
@@ -45,15 +45,17 @@ void main() {
group('hydrate', () {
test('populates the open-buffer list and loads the active buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart'), _buf('b_2', 'lib/b.dart', dirty: true)]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart'), _buf('b_2', 'lib/b.dart', dirty: true)],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (a) async => _ok(_read('b_1', 'lib/a.dart', 'hello')));
await c.hydrate();
@@ -66,10 +68,11 @@ void main() {
test('with no active buffer leaves the list but no active content', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart')],
}),
);
ipc.stub('editor.active', (_) async => _ok(const {})); // no `active` key
await c.hydrate();
@@ -93,15 +96,17 @@ void main() {
test('activate is a no-op when the id is already active', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
@@ -129,15 +134,17 @@ void main() {
test('editor.opened refreshes the list and loads the new active buffer', () async {
var listVersion = 1;
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart')] : [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart')] : [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (a) async {
final id = a['id'] as String;
return _ok(_read(id, id == 'b_1' ? 'a.dart' : 'b.dart', 'content-$id'));
@@ -157,15 +164,17 @@ void main() {
test('editor.closed refreshes the list and clears active when it was active', () async {
var listVersion = 1;
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')] : [_buf('b_2', 'b.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')] : [_buf('b_2', 'b.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (a) async => _ok(_read(a['id'] as String, 'a.dart', 'x')));
await c.hydrate();
expect(c.buffers, hasLength(2));
@@ -180,15 +189,17 @@ void main() {
test('editor.saved clears the dirty marker on the buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart', dirty: true)]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart', dirty: true)],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x', dirty: true)));
await c.hydrate();
expect(c.buffers.single.dirty, isTrue);
@@ -202,15 +213,17 @@ void main() {
test('editor.edited marks the right buffer dirty, leaving siblings alone', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (a) async => _ok(_read(a['id'] as String, 'a.dart', 'x')));
await c.hydrate();
expect(c.buffers.every((b) => !b.dirty), isTrue);
@@ -226,15 +239,17 @@ void main() {
test('editor.active-changed to a different buffer loads its content', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (a) async {
final id = a['id'] as String;
return _ok(_read(id, id == 'b_1' ? 'a.dart' : 'b.dart', 'body-$id'));
@@ -253,15 +268,17 @@ void main() {
group('local edits', () {
test('pushLocalEdit marks the active buffer dirty and mirrors to IPC', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
@@ -281,15 +298,17 @@ void main() {
test('save() issues editor.save for the active buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
@@ -316,15 +335,17 @@ void main() {
group('edge cases', () {
test('editor.active-changed to null clears the active buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
expect(c.activeId, 'b_1');
@@ -358,15 +379,17 @@ void main() {
test('editor.opened with no id clears the active buffer', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
await c.hydrate();
expect(c.activeId, 'b_1');
@@ -378,15 +401,17 @@ void main() {
test('a failing editor.read surfaces the error', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub(
'editor.read',
(_) async => IpcResponse.err(
@@ -400,15 +425,17 @@ void main() {
test('our own edit echo (editor.edited) is suppressed once, not reloaded', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'original')));
ipc.stub('editor.set-content', (_) async => _ok(const {}));
await c.hydrate();
@@ -433,15 +460,17 @@ void main() {
group('editor settings (T-29)', () {
Future<void> hydrateWith(Map<String, Object?> read) async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart')]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'lib/a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(read));
await c.hydrate();
}
+44 -38
View File
@@ -21,13 +21,13 @@ IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data)
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
Map<String, Object?> _read(String id, String path, {Map<String, Object?>? settings}) => {
'id': id,
'path': path,
'content': 'content of $path',
'selection': {'start': 0, 'end': 0},
'dirty': false,
if (settings != null) 'editorSettings': settings,
};
'id': id,
'path': path,
'content': 'content of $path',
'selection': {'start': 0, 'end': 0},
'dirty': false,
'editorSettings': ?settings,
};
Finder _ruler() => find.byWidgetPredicate((w) => w is CustomPaint && w.painter?.runtimeType.toString() == '_RulerPainter');
@@ -40,12 +40,13 @@ void main() {
void stubBuffers(List<Map<String, Object?>> buffers, {String? active}) {
f.ipc.stub('editor.list', (_) async => _ok({'buffers': buffers}));
f.ipc.stub(
'editor.active',
(_) async => active == null
? _ok(const {})
: _ok({
'active': {'id': active}
}));
'editor.active',
(_) async => active == null
? _ok(const {})
: _ok({
'active': {'id': active},
}),
);
f.ipc.stub('editor.read', (a) async {
final id = a['id'] as String;
final b = buffers.firstWhere((b) => b['id'] == id);
@@ -147,15 +148,17 @@ void main() {
void stubOne(String path, {Map<String, Object?>? settings}) {
f.ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', path)]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', path)],
}),
);
f.ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
f.ipc.stub('editor.read', (_) async => _ok(_read('b_1', path, settings: settings)));
}
@@ -178,25 +181,28 @@ void main() {
void stubReadContent(String path, String content, Map<String, Object?> settings) {
f.ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', path)]
}));
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', path)],
}),
);
f.ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
f.ipc.stub(
'editor.read',
(_) async => _ok({
'id': 'b_1',
'path': path,
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': false,
'editorSettings': settings
}));
'editor.read',
(_) async => _ok({
'id': 'b_1',
'path': path,
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': false,
'editorSettings': settings,
}),
);
f.ipc.stub('editor.set-content', (_) async => _ok(const {}));
}
@@ -41,26 +41,30 @@ void main() {
/// Pump a themed context, run [body] with a controller already
/// given tokens from the active theme.
Future<void> withController(
WidgetTester tester,
SyntaxTextController c,
Future<void> Function(BuildContext ctx) body,
) async {
Future<void> withController(WidgetTester tester, SyntaxTextController c, Future<void> Function(BuildContext ctx) body) async {
late BuildContext ctx;
await tester.pumpWidget(harness(f, Builder(builder: (context) {
ctx = context;
return const SizedBox();
})));
await tester.pumpWidget(
harness(
f,
Builder(
builder: (context) {
ctx = context;
return const SizedBox();
},
),
),
);
c.tokens = ClideTheme.of(ctx).surface;
await body(ctx);
}
testWidgets('renders highlighted spans as styled TextSpan children', (tester) async {
final c = SyntaxTextController(
syntax: _FakeSyntax(const [
SyntaxSpan(start: 0, end: 5, role: 'keyword'), // "class"
SyntaxSpan(start: 6, end: 9, role: 'type'), // "Foo"
]));
syntax: _FakeSyntax(const [
SyntaxSpan(start: 0, end: 5, role: 'keyword'), // "class"
SyntaxSpan(start: 6, end: 9, role: 'type'), // "Foo"
]),
);
await withController(tester, c, (ctx) async {
c.text = 'class Foo {}';
c.updatePath('a.dart');
@@ -76,9 +80,10 @@ void main() {
// '😀' is a surrogate pair (4 UTF-8 bytes). A span after it must
// still land on the right character offset.
final c = SyntaxTextController(
syntax: _FakeSyntax(const [
SyntaxSpan(start: 5, end: 8, role: 'type'), // "Foo" after "😀 "
]));
syntax: _FakeSyntax(const [
SyntaxSpan(start: 5, end: 8, role: 'type'), // "Foo" after "😀 "
]),
);
await withController(tester, c, (ctx) async {
c.text = '😀 Foo';
c.updatePath('a.dart');
+3 -3
View File
@@ -8,9 +8,9 @@ import 'package:flutter/services.dart' show TextEditingValue, TextSelection;
import 'package:flutter_test/flutter_test.dart';
TextEditingValue _tev(String text, int caret, {int? anchor}) => TextEditingValue(
text: text,
selection: TextSelection(baseOffset: anchor ?? caret, extentOffset: caret),
);
text: text,
selection: TextSelection(baseOffset: anchor ?? caret, extentOffset: caret),
);
void main() {
group('motions', () {
+21 -18
View File
@@ -22,26 +22,29 @@ void main() {
void stubOneBuffer(String content) {
f.ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [
{'id': 'b_1', 'path': 'lib/a.dart', 'dirty': false}
]
}));
'editor.list',
(_) async => _ok({
'buffers': [
{'id': 'b_1', 'path': 'lib/a.dart', 'dirty': false},
],
}),
);
f.ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'}
}));
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
f.ipc.stub(
'editor.read',
(_) async => _ok({
'id': 'b_1',
'path': 'lib/a.dart',
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': false,
}));
'editor.read',
(_) async => _ok({
'id': 'b_1',
'path': 'lib/a.dart',
'content': content,
'selection': {'start': 0, 'end': 0},
'dirty': false,
}),
);
}
Future<void> pumpEditor(WidgetTester tester) async {
@@ -19,13 +19,9 @@ import '../../helpers/kernel_fixture.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
IpcResponse _err(String msg) => IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: msg,
),
);
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
);
Map<String, Object?> _fileEntry({
required String name,
@@ -34,15 +30,7 @@ Map<String, Object?> _fileEntry({
bool isSymlink = false,
int? sizeBytes,
int? modifiedMs,
}) =>
{
'name': name,
'path': path,
'isDirectory': isDirectory,
'isSymlink': isSymlink,
'sizeBytes': sizeBytes,
'modifiedMs': modifiedMs,
};
}) => {'name': name, 'path': path, 'isDirectory': isDirectory, 'isSymlink': isSymlink, 'sizeBytes': sizeBytes, 'modifiedMs': modifiedMs};
// ---------------------------------------------------------------------------
// Tests
@@ -75,9 +63,7 @@ void main() {
f.ipc.stub(
'files.ls',
(_) async => _ok({
'entries': [
_fileEntry(name: 'main.dart', path: 'lib/main.dart'),
],
'entries': [_fileEntry(name: 'main.dart', path: 'lib/main.dart')],
}),
);
@@ -148,17 +134,12 @@ void main() {
final path = args['path'] as String? ?? '';
if (path == '') {
return _ok({
'entries': [
_fileEntry(name: 'lib', path: 'lib', isDirectory: true),
_fileEntry(name: 'main.dart', path: 'main.dart'),
],
'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'main.dart', path: 'main.dart')],
});
}
if (path == 'lib') {
return _ok({
'entries': [
_fileEntry(name: 'app.dart', path: 'lib/app.dart'),
],
'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')],
});
}
return _ok({'entries': <Object?>[]});
@@ -243,17 +224,12 @@ void main() {
final path = args['path'] as String? ?? '';
if (path == '') {
return _ok({
'entries': [
_fileEntry(name: 'lib', path: 'lib', isDirectory: true),
_fileEntry(name: 'README.md', path: 'README.md'),
],
'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'README.md', path: 'README.md')],
});
}
if (path == 'lib') {
return _ok({
'entries': [
_fileEntry(name: 'main.dart', path: 'lib/main.dart'),
],
'entries': [_fileEntry(name: 'main.dart', path: 'lib/main.dart')],
});
}
return _ok({'entries': <Object?>[]});
@@ -309,12 +285,7 @@ void main() {
final countAfterLoad = lsCallCount;
// Emit files.changed for a file at root level — parent is ''.
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': 'README.md'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
// Give the async refresh a tick.
await Future<void>.delayed(Duration.zero);
@@ -333,12 +304,7 @@ void main() {
final countAfterLoad = lsCallCount;
// 'lib' is not in _entries yet, so its parent 'lib/src' won't be there.
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': 'lib/src/foo.dart'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'lib/src/foo.dart'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
expect(lsCallCount, countAfterLoad);
@@ -355,12 +321,7 @@ void main() {
await c.load();
final countAfterLoad = lsCallCount;
f.services.events.emit(DaemonEvent(
subsystem: 'editor',
kind: 'files.changed',
data: {'path': 'README.md'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'files.changed', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
expect(lsCallCount, countAfterLoad);
@@ -377,12 +338,7 @@ void main() {
await c.load();
final countAfterLoad = lsCallCount;
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.opened',
data: {'path': 'README.md'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.opened', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
expect(lsCallCount, countAfterLoad);
@@ -395,12 +351,7 @@ void main() {
await c.load();
final countAfterLoad = 1;
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': 'pubspec.yaml'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'pubspec.yaml'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
// Root '' is in _entries, so reload fires.
@@ -412,12 +363,7 @@ void main() {
final c = makeCtrl();
await c.load();
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': null},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': null}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
// No crash — just checking the null-path guard.
});
@@ -448,12 +394,7 @@ void main() {
// Dispose and then emit an event — must not crash.
c.dispose();
ctrl = null; // prevent tearDown from double-disposing
f.services.events.emit(DaemonEvent(
subsystem: 'files',
kind: 'files.changed',
data: {'path': 'README.md'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
// Test passes if no exception.
});
+3 -21
View File
@@ -25,24 +25,13 @@ IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data)
///
/// The [root] entry list populates the workspace root directory so the tree
/// renders at least one file row.
void _stubTree(
KernelFixture f, {
required String rootPath,
required List<Map<String, Object?>> entries,
}) {
void _stubTree(KernelFixture f, {required String rootPath, required List<Map<String, Object?>> entries}) {
f.ipc.stub('files.root', (_) async => _ok({'path': rootPath}));
f.ipc.stub('files.watch', (_) async => _ok(const {}));
f.ipc.stub('files.ls', (args) async => _ok({'entries': entries}));
}
Map<String, Object?> _file(String name, String path) => {
'name': name,
'path': path,
'isDirectory': false,
'isSymlink': false,
'sizeBytes': 0,
'modifiedMs': 0,
};
Map<String, Object?> _file(String name, String path) => {'name': name, 'path': path, 'isDirectory': false, 'isSymlink': false, 'sizeBytes': 0, 'modifiedMs': 0};
// ---------------------------------------------------------------------------
// Tests
@@ -108,14 +97,7 @@ void main() {
testWidgets('clicking a .md in the filtered view publishes to builtin.markdown selection', (tester) async {
const mdPath = 'governance/decisions/architecture.md';
_stubTree(
f,
rootPath: '/repo',
entries: [
_file('architecture.md', mdPath),
_file('tooling.md', 'governance/decisions/tooling.md'),
],
);
_stubTree(f, rootPath: '/repo', entries: [_file('architecture.md', mdPath), _file('tooling.md', 'governance/decisions/tooling.md')]);
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
+25 -24
View File
@@ -28,9 +28,9 @@ void main() {
IpcResponse ok([Map<String, Object?> data = const {}]) => IpcResponse.ok(id: '', data: data);
IpcResponse err(String message) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message),
);
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message),
);
// Let the broadcast streams (bus / events) deliver.
Future<void> settle() => Future<void>.delayed(Duration.zero);
@@ -38,27 +38,28 @@ void main() {
group('load + status parsing', () {
test('hydrates branch / counts / file lists from git.status', () async {
f.ipc.stub(
'git.status',
(_) async => ok({
'branch': 'main',
'upstream': 'origin/main',
'ahead': 2,
'behind': 1,
'clean': false,
'hasConflicts': true,
'staged': [
{'path': 'a.dart'},
],
'unstaged': [
{'path': 'b.dart'},
],
'untracked': [
{'path': 'c.dart'},
],
'conflicted': [
{'path': 'd.dart'},
],
}));
'git.status',
(_) async => ok({
'branch': 'main',
'upstream': 'origin/main',
'ahead': 2,
'behind': 1,
'clean': false,
'hasConflicts': true,
'staged': [
{'path': 'a.dart'},
],
'unstaged': [
{'path': 'b.dart'},
],
'untracked': [
{'path': 'c.dart'},
],
'conflicted': [
{'path': 'd.dart'},
],
}),
);
final c = controller();
await c.load();
expect(c.loading, isFalse);
+13 -17
View File
@@ -39,12 +39,7 @@ void main() {
});
testWidgets('all-tools-resolved shows a single "application ok" chip', (tester) async {
f.services.toolchain.applyResolved(const ResolvedPaths(
git: '/usr/bin/git',
pql: '/usr/bin/pql',
tmux: '/usr/bin/tmux',
shell: '/bin/bash',
));
f.services.toolchain.applyResolved(const ResolvedPaths(git: '/usr/bin/git', pql: '/usr/bin/pql', tmux: '/usr/bin/tmux', shell: '/bin/bash'));
await tester.pumpWidget(harness(f, const ToolStatusItem()));
await tester.pumpAndSettle();
expect(find.text('application ok'), findsOneWidget);
@@ -52,10 +47,7 @@ void main() {
testWidgets('missing tools render a warning chip per missing tool', (tester) async {
// git + tmux missing, pql resolved.
f.services.toolchain.applyResolved(const ResolvedPaths(
pql: '/usr/bin/pql',
shell: '/bin/bash',
));
f.services.toolchain.applyResolved(const ResolvedPaths(pql: '/usr/bin/pql', shell: '/bin/bash'));
await tester.pumpWidget(harness(f, const ToolStatusItem()));
await tester.pumpAndSettle();
expect(find.text('git not found'), findsOneWidget);
@@ -69,13 +61,17 @@ void main() {
final item = f.services.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().first;
// Pump a Builder so we have a real BuildContext to hand to .build.
late Widget produced;
await tester.pumpWidget(harness(
f,
Builder(builder: (ctx) {
produced = item.build(ctx);
return const SizedBox.shrink();
}),
));
await tester.pumpWidget(
harness(
f,
Builder(
builder: (ctx) {
produced = item.build(ctx);
return const SizedBox.shrink();
},
),
),
);
expect(produced, isA<ToolStatusItem>());
});
});
+31 -105
View File
@@ -25,7 +25,10 @@ IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data)
void _stubRead(KernelFixture f, String path, String content) {
f.ipc.stub('files.read', (args) async {
if ((args['path'] as String?) == path) return _ok({'content': content});
return IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'not found'));
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'not found'),
);
});
}
@@ -34,7 +37,10 @@ void _stubReadMap(KernelFixture f, Map<String, String> paths) {
f.ipc.stub('files.read', (args) async {
final path = args['path'] as String? ?? '';
if (paths.containsKey(path)) return _ok({'content': paths[path]!});
return IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'not found'));
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'not found'),
);
});
}
@@ -187,12 +193,7 @@ void main() {
// Back should be disabled — the button is in a disabled state (no back entry).
// We verify via the action-bar semantics label.
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false,
),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false), findsOneWidget);
});
testWidgets('back enabled after loading two files', (tester) async {
@@ -202,12 +203,7 @@ void main() {
await loadFile(tester, f, 'b.md');
// Back should be enabled.
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
),
findsWidgets,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true)), findsWidgets);
});
testWidgets('back navigates to previous file', (tester) async {
@@ -220,9 +216,7 @@ void main() {
expect(find.text('b.md'), findsOneWidget);
// Tap Back.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backButton = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backButton.first);
await pumpAsync(tester);
@@ -236,12 +230,7 @@ void main() {
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false), findsOneWidget);
});
testWidgets('forward enabled after going back', (tester) async {
@@ -251,19 +240,12 @@ void main() {
await loadFile(tester, f, 'b.md');
// Go back.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backButton = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backButton.first);
await pumpAsync(tester);
// Forward should now be enabled.
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
),
findsWidgets,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true)), findsWidgets);
});
testWidgets('forward navigates to next file after back', (tester) async {
@@ -273,17 +255,13 @@ void main() {
await loadFile(tester, f, 'b.md');
// Go back to a.md.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backButton = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backButton.first);
await pumpAsync(tester);
expect(find.text('a.md'), findsOneWidget);
// Go forward to b.md.
final fwdButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
);
final fwdButton = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true));
await tester.tap(fwdButton.first);
await pumpAsync(tester);
expect(find.text('b.md'), findsOneWidget);
@@ -296,9 +274,7 @@ void main() {
await loadFile(tester, f, 'b.md');
// Go back to a.md.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
final backButton = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true));
await tester.tap(backButton.first);
await pumpAsync(tester);
@@ -306,12 +282,7 @@ void main() {
await loadFile(tester, f, 'c.md');
// Forward should now be disabled (b.md was truncated).
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false), findsOneWidget);
});
});
@@ -325,10 +296,7 @@ void main() {
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
expect(
find.byWidgetPredicate((w) => w is Semantics && (w.properties.label == 'Pin' || w.properties.label == 'Unpin')),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && (w.properties.label == 'Pin' || w.properties.label == 'Unpin')), findsOneWidget);
});
testWidgets('pin jump affordance not visible before pin is set', (tester) async {
@@ -336,10 +304,7 @@ void main() {
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsNothing);
});
testWidgets('pin current shows jump-to-pin affordance', (tester) async {
@@ -348,18 +313,11 @@ void main() {
await loadFile(tester, f, 'a.md');
// Tap Pin current.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first);
await pumpAsync(tester);
// Jump-to-pin affordance should now be visible.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsOneWidget);
});
testWidgets('jump to pin loads the pinned file', (tester) async {
@@ -368,11 +326,7 @@ void main() {
await loadFile(tester, f, 'a.md');
// Pin a.md.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first);
await pumpAsync(tester);
// Navigate to b.md.
@@ -380,11 +334,7 @@ void main() {
expect(find.text('b.md'), findsOneWidget);
// Jump to pin.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Jump to pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin').first);
await pumpAsync(tester);
// Should be back at a.md.
@@ -397,28 +347,14 @@ void main() {
await loadFile(tester, f, 'a.md');
// Pin a.md → jump-to-pin appears.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Pin').first);
await pumpAsync(tester);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsOneWidget);
// Tap the toggle again (now 'Unpin') → pin cleared.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Unpin',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Unpin').first);
await pumpAsync(tester);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'), findsNothing);
});
});
@@ -430,10 +366,7 @@ void main() {
testWidgets('edit pencil not visible before a file is loaded', (tester) async {
await pumpView(tester, f);
// No file loaded — placeholder shown, no chrome.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsNothing,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'), findsNothing);
});
testWidgets('edit pencil fires editor.open with current path', (tester) async {
@@ -449,16 +382,9 @@ void main() {
await pumpView(tester, f);
await loadFile(tester, f, path);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'), findsOneWidget);
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Edit in editor',
)
.first);
await tester.tap(find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor').first);
await pumpAsync(tester);
expect(editorOpenArgs, hasLength(1));
+13 -19
View File
@@ -45,25 +45,22 @@ void main() {
});
Widget harness(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(alignment: Alignment.topLeft, child: child),
),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(alignment: Alignment.topLeft, child: child),
),
);
),
),
);
testWidgets('OpenFolderDialog submits the typed path via onOpen', (tester) async {
String? opened;
await tester.pumpWidget(harness(OpenFolderDialog(
onOpen: (p) async => opened = p,
onCancel: () {},
)));
await tester.pumpWidget(harness(OpenFolderDialog(onOpen: (p) async => opened = p, onCancel: () {})));
await tester.enterText(find.byType(EditableText), '/some/repo');
await tester.tap(find.text('Open'));
await tester.pump();
@@ -71,10 +68,7 @@ void main() {
});
testWidgets('OpenFolderDialog surfaces an error when onOpen throws', (tester) async {
await tester.pumpWidget(harness(OpenFolderDialog(
onOpen: (_) async => throw StateError('not a repo'),
onCancel: () {},
)));
await tester.pumpWidget(harness(OpenFolderDialog(onOpen: (_) async => throw StateError('not a repo'), onCancel: () {})));
await tester.enterText(find.byType(EditableText), '/bad');
await tester.tap(find.text('Open'));
await tester.pump();
+33 -26
View File
@@ -22,12 +22,14 @@ void main() {
f.services.extensions.register(MenuBarExtension(services: f.services));
await f.services.extensions.activateAll();
// A registered View command so the View menu has an enabled item.
f.services.commands.register(CommandContribution(
id: 'view.zoomIn',
command: 'view.zoomIn',
title: 'View: Zoom In',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: 'view.zoomIn',
command: 'view.zoomIn',
title: 'View: Zoom In',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
controller = MenuBarController();
});
@@ -37,32 +39,37 @@ void main() {
});
Widget harness() => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 900,
height: 600,
child: DialogHost(
router: f.services.dialog,
child: Overlay(
initialEntries: [
OverlayEntry(builder: (_) => Align(alignment: Alignment.topLeft, child: MenuBar(controller: controller))),
],
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: MediaQuery(
data: const MediaQueryData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 900,
height: 600,
child: DialogHost(
router: f.services.dialog,
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Align(
alignment: Alignment.topLeft,
child: MenuBar(controller: controller),
),
),
),
],
),
),
),
),
),
);
),
),
);
Future<void> openMenu(WidgetTester tester, String title) async {
await tester.tap(find.text(title));
+26 -24
View File
@@ -17,24 +17,30 @@ void main() {
tearDown(() => f.dispose());
void cmd(String id, {String? title, String? binding}) {
f.services.commands.register(CommandContribution(
id: id,
command: id,
title: title,
defaultBinding: binding,
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: id,
command: id,
title: title,
defaultBinding: binding,
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
}
group('resolveMenus', () {
test('curated order: strips "Category:" titles, separators pass through, defaultBinding shows', () {
cmd('a.one', title: 'A: One', binding: 'ctrl+1');
final tree = [
TopMenu(title: 'A', mnemonic: 0, nodes: const [
MenuCommandItem('a.one'),
MenuSeparator(),
MenuCommandItem('a.missing', fallbackTitle: 'Missing'),
]),
TopMenu(
title: 'A',
mnemonic: 0,
nodes: const [
MenuCommandItem('a.one'),
MenuSeparator(),
MenuCommandItem('a.missing', fallbackTitle: 'Missing'),
],
),
];
final items = resolveMenus(tree, f.services.commands, f.services).single.items;
@@ -55,11 +61,7 @@ void main() {
cmd('view.beta', title: 'View: Beta');
cmd('view.alpha', title: 'View: Alpha');
final tree = [
TopMenu(title: 'View', mnemonic: 0, nodes: const [
MenuCommandItem('view.zoomIn'),
MenuSeparator(),
MenuAutoFill('view.'),
]),
TopMenu(title: 'View', mnemonic: 0, nodes: const [MenuCommandItem('view.zoomIn'), MenuSeparator(), MenuAutoFill('view.')]),
];
final items = resolveMenus(tree, f.services.commands, f.services).single.items.whereType<ResolvedItem>().toList();
// zoomIn (placed) first; then auto-filled Alpha, Beta sorted by title;
@@ -71,7 +73,11 @@ void main() {
cmd('x.cmd', title: 'X: Cmd');
List<ResolvedItem> resolve(bool Function(KernelServices) when) {
final tree = [
TopMenu(title: 'X', mnemonic: 0, nodes: [MenuCommandItem('x.cmd', enabledWhen: when)]),
TopMenu(
title: 'X',
mnemonic: 0,
nodes: [MenuCommandItem('x.cmd', enabledWhen: when)],
),
];
return resolveMenus(tree, f.services.commands, f.services).single.items.cast<ResolvedItem>();
}
@@ -85,12 +91,8 @@ void main() {
final tree = [
TopMenu(title: 'K', mnemonic: 0, nodes: const [MenuCommandItem('k.cmd')]),
];
final item = resolveMenus(
tree,
f.services.commands,
f.services,
bindingLabel: (id) => id == 'k.cmd' ? 'Ctrl+K' : null,
).single.items.first as ResolvedItem;
final item =
resolveMenus(tree, f.services.commands, f.services, bindingLabel: (id) => id == 'k.cmd' ? 'Ctrl+K' : null).single.items.first as ResolvedItem;
expect(item.keybinding, 'Ctrl+K');
});
});
+4 -16
View File
@@ -71,10 +71,7 @@ void main() {
await tester.pumpWidget(harness(f, OutputView(ring: _seeded())));
await tester.pumpAndSettle();
expect(find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')), findsOneWidget);
await tester.tap(find.ancestor(
of: find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')),
matching: find.byType(GestureDetector),
));
await tester.tap(find.ancestor(of: find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')), matching: find.byType(GestureDetector)));
await tester.pumpAndSettle();
expect(find.byWidgetPredicate((w) => _textIs(w, 'Level: info')), findsOneWidget);
});
@@ -83,10 +80,7 @@ void main() {
await tester.pumpWidget(harness(f, OutputView(ring: _seeded())));
await tester.pumpAndSettle();
expect(find.byWidgetPredicate((w) => _textIs(w, 'Source: all')), findsOneWidget);
await tester.tap(find.ancestor(
of: find.byWidgetPredicate((w) => _textIs(w, 'Source: all')),
matching: find.byType(GestureDetector),
));
await tester.tap(find.ancestor(of: find.byWidgetPredicate((w) => _textIs(w, 'Source: all')), matching: find.byType(GestureDetector)));
await tester.pumpAndSettle();
// First source alphabetically is 'extensions'.
expect(find.byWidgetPredicate((w) => _textIs(w, 'Source: extensions')), findsOneWidget);
@@ -99,10 +93,7 @@ void main() {
await tester.pumpWidget(harness(f, OutputView(ring: ring)));
await tester.pumpAndSettle();
// Cycle level debug → info, hiding the only (debug) record.
await tester.tap(find.ancestor(
of: find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')),
matching: find.byType(GestureDetector),
));
await tester.tap(find.ancestor(of: find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')), matching: find.byType(GestureDetector)));
await tester.pumpAndSettle();
expect(find.byWidgetPredicate((w) => _textIs(w, 'No output matches the filter.')), findsOneWidget);
});
@@ -133,10 +124,7 @@ void main() {
testWidgets('Clear empties the view', (tester) async {
await tester.pumpWidget(harness(f, OutputView(ring: _seeded())));
await tester.pumpAndSettle();
await tester.tap(find.ancestor(
of: find.byWidgetPredicate((w) => _textIs(w, 'Clear')),
matching: find.byType(GestureDetector),
));
await tester.tap(find.ancestor(of: find.byWidgetPredicate((w) => _textIs(w, 'Clear')), matching: find.byType(GestureDetector)));
await tester.pumpAndSettle();
expect(find.byWidgetPredicate((w) => _textIs(w, 'alpha')), findsNothing);
expect(find.byWidgetPredicate((w) => _textIs(w, 'No output yet.')), findsOneWidget);
+31 -27
View File
@@ -12,9 +12,9 @@ import '../../helpers/kernel_fixture.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
IpcResponse _err(String m) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: m),
);
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: m),
);
void main() {
late KernelFixture f;
@@ -48,12 +48,13 @@ void main() {
test('search populates ranked results', () async {
f.ipc.stub(
'pql.search',
(args) async => _ok({
'results': [
{'path': 'a.md', 'score': 0.8},
],
}));
'pql.search',
(args) async => _ok({
'results': [
{'path': 'a.md', 'score': 0.8},
],
}),
);
await c.search('term');
expect(c.results.single['path'], 'a.md');
expect(c.error, isNull);
@@ -78,12 +79,13 @@ void main() {
test('runQuery populates rows; error surfaces', () async {
f.ipc.stub(
'pql.query',
(args) async => _ok({
'results': [
{'name': 'T-1'},
],
}));
'pql.query',
(args) async => _ok({
'results': [
{'name': 'T-1'},
],
}),
);
await c.runQuery("type = 'ticket'");
expect(c.results.single['name'], 'T-1');
@@ -95,12 +97,13 @@ void main() {
test('loadMarkdownFiles populates + errors', () async {
f.ipc.stub(
'pql.files',
(args) async => _ok({
'files': [
{'path': 'docs/x.md'},
],
}));
'pql.files',
(args) async => _ok({
'files': [
{'path': 'docs/x.md'},
],
}),
);
await c.loadMarkdownFiles();
expect(c.results.single['path'], 'docs/x.md');
@@ -126,12 +129,13 @@ void main() {
test('setSearchMode + toggleSearchMode flip the mode and clear results', () async {
f.ipc.stub(
'pql.search',
(_) async => _ok({
'results': [
{'path': 'a.md', 'score': 0.5},
],
}));
'pql.search',
(_) async => _ok({
'results': [
{'path': 'a.md', 'score': 0.5},
],
}),
);
await c.search('x');
expect(c.results, isNotEmpty);
c.setSearchMode(SearchMode.dsl);
@@ -31,12 +31,7 @@ void main() {
FindInFilesController make() => ctrl = FindInFilesController(ipc: f.ipc, events: f.services.events);
void emitMatch(String id, List<Map<String, Object?>> matches) {
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.match',
data: {'searchId': id, 'matches': matches},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(DaemonEvent(subsystem: 'search', kind: 'search.match', data: {'searchId': id, 'matches': matches}, ts: DateTime.now().toUtc()));
}
Map<String, Object?> m(String path, int line) => {'path': path, 'line': line, 'matchStart': 0, 'matchEnd': 3, 'preview': 'foo bar'};
@@ -93,12 +88,9 @@ void main() {
test('search.done clears running', () async {
final c = make();
await c.run('foo');
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.done',
data: const {'searchId': 's1', 'cancelled': false},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(
DaemonEvent(subsystem: 'search', kind: 'search.done', data: const {'searchId': 's1', 'cancelled': false}, ts: DateTime.now().toUtc()),
);
await Future<void>.delayed(Duration.zero);
expect(c.running, isFalse);
expect(c.done, isTrue);
@@ -107,12 +99,9 @@ void main() {
test('search.error surfaces the message', () async {
final c = make();
await c.run('(bad');
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.error',
data: const {'searchId': 's1', 'message': 'invalid regex: x'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(
DaemonEvent(subsystem: 'search', kind: 'search.error', data: const {'searchId': 's1', 'message': 'invalid regex: x'}, ts: DateTime.now().toUtc()),
);
await Future<void>.delayed(Duration.zero);
expect(c.error, contains('invalid regex'));
expect(c.running, isFalse);
@@ -151,11 +140,12 @@ void main() {
test('a failed search.grep surfaces the error', () async {
f.ipc.stub(
'search.grep',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: 'nope'),
));
'search.grep',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: 'nope'),
),
);
final c = make();
await c.run('foo');
expect(c.error, 'nope');
+33 -38
View File
@@ -28,17 +28,19 @@ void main() {
tearDown(() => f.dispose());
void emitMatches() {
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.match',
data: const {
'searchId': 's1',
'matches': [
{'path': 'lib/a.dart', 'line': 12, 'matchStart': 6, 'matchEnd': 9, 'preview': 'final foo = 1;'},
],
},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(
DaemonEvent(
subsystem: 'search',
kind: 'search.match',
data: const {
'searchId': 's1',
'matches': [
{'path': 'lib/a.dart', 'line': 12, 'matchStart': 6, 'matchEnd': 9, 'preview': 'final foo = 1;'},
],
},
ts: DateTime.now().toUtc(),
),
);
}
testWidgets('search renders matches grouped by file', (tester) async {
@@ -79,12 +81,9 @@ void main() {
await tester.enterText(find.byType(EditableText).first, 'foo');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.done',
data: const {'searchId': 's1', 'cancelled': false},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(
DaemonEvent(subsystem: 'search', kind: 'search.done', data: const {'searchId': 's1', 'cancelled': false}, ts: DateTime.now().toUtc()),
);
await pumpAsync(tester);
expect(find.text('No results'), findsOneWidget);
});
@@ -94,12 +93,9 @@ void main() {
await tester.enterText(find.byType(EditableText).first, '(bad');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
f.services.events.emit(DaemonEvent(
subsystem: 'search',
kind: 'search.error',
data: const {'searchId': 's1', 'message': 'invalid regex: boom'},
ts: DateTime.now().toUtc(),
));
f.services.events.emit(
DaemonEvent(subsystem: 'search', kind: 'search.error', data: const {'searchId': 's1', 'message': 'invalid regex: boom'}, ts: DateTime.now().toUtc()),
);
await pumpAsync(tester);
expect(find.textContaining('invalid regex'), findsOneWidget);
});
@@ -136,10 +132,7 @@ void main() {
await seedReplace(tester);
// The emitted match line is 'final foo = 1;' → preview shows the after
// (rendered as a RichText span, so match on the plain text).
expect(
find.byWidgetPredicate((w) => w is RichText && w.text.toPlainText() == 'final bar = 1;'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is RichText && w.text.toPlainText() == 'final bar = 1;'), findsOneWidget);
});
testWidgets('Replace all on a dirty tree shows a guard dialog, no apply', (tester) async {
@@ -226,12 +219,13 @@ void main() {
testWidgets('Markdown mode lists markdown files on switch', (tester) async {
f.ipc.stub(
'pql.files',
(_) async => _ok({
'files': [
{'path': 'docs/initial-plan.md'},
],
}));
'pql.files',
(_) async => _ok({
'files': [
{'path': 'docs/initial-plan.md'},
],
}),
);
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
@@ -243,11 +237,12 @@ void main() {
testWidgets('Vault mode surfaces a pql search error', (tester) async {
f.ipc.stub(
'pql.search',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'pql down'),
));
'pql.search',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'pql down'),
),
);
await tester.pumpWidget(harness(f, const SearchPanelView()));
await pumpAsync(tester);
await tester.tap(find.text('Vault'));
+20 -52
View File
@@ -10,21 +10,21 @@ import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
ThemeDefinition _def(String name) => ThemeDefinition(
name: name,
displayName: name,
dark: true,
palette: Palette(const {
'primary': Color(0xFF00A3D2),
'accent': Color(0xFFFA5F8B),
'background': Color(0xFF21262F),
'surface': Color(0xFF393E48),
'panel': Color(0xFF292E38),
'foreground': Color(0xFFE2E8F5),
'success': Color(0xFF00AB9A),
'warning': Color(0xFFD08447),
'error': Color(0xFFF06C6F),
}),
);
name: name,
displayName: name,
dark: true,
palette: Palette(const {
'primary': Color(0xFF00A3D2),
'accent': Color(0xFFFA5F8B),
'background': Color(0xFF21262F),
'surface': Color(0xFF393E48),
'panel': Color(0xFF292E38),
'foreground': Color(0xFFE2E8F5),
'success': Color(0xFF00AB9A),
'warning': Color(0xFFD08447),
'error': Color(0xFFF06C6F),
}),
);
void main() {
group('ThemePickerExtension', () {
@@ -59,22 +59,11 @@ void main() {
test('default binding ctrl+k is registered', () async {
f.services.extensions.register(ThemePickerExtension());
await f.services.extensions.activateAll();
expect(
f.services.keybindings.commandFor(Keybinding.parse('ctrl+k')),
'theme.pick',
);
expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+k')), 'theme.pick');
});
testWidgets('settings modal lists base themes + a High contrast toggle', (tester) async {
await tester.pumpWidget(
harness(
f,
SettingsView(
controller: f.services.theme,
onDismiss: ([_]) {},
),
),
);
await tester.pumpWidget(harness(f, SettingsView(controller: f.services.theme, onDismiss: ([_]) {})));
// Each row renders both displayName and name; displayName==name in
// test fixtures so the label appears twice per row.
expect(find.text('summer-night'), findsNWidgets(2));
@@ -88,15 +77,7 @@ void main() {
testWidgets('tapping a row calls controller.select + onDismiss', (tester) async {
String? dismissed;
await tester.pumpWidget(
harness(
f,
SettingsView(
controller: f.services.theme,
onDismiss: ([v]) => dismissed = v,
),
),
);
await tester.pumpWidget(harness(f, SettingsView(controller: f.services.theme, onDismiss: ([v]) => dismissed = v)));
await tester.tap(find.bySemanticsLabel('forest'));
await tester.pumpAndSettle();
expect(f.services.theme.currentName, 'forest');
@@ -105,15 +86,7 @@ void main() {
testWidgets('Cancel button dismisses without selecting', (tester) async {
String? dismissed = 'not-called';
await tester.pumpWidget(
harness(
f,
SettingsView(
controller: f.services.theme,
onDismiss: ([v]) => dismissed = v,
),
),
);
await tester.pumpWidget(harness(f, SettingsView(controller: f.services.theme, onDismiss: ([v]) => dismissed = v)));
await tester.tap(find.bySemanticsLabel('Cancel'));
await tester.pumpAndSettle();
expect(dismissed, isNull);
@@ -126,12 +99,7 @@ void main() {
late KernelFixture hf;
await tester.runAsync(() async => hf = await KernelFixture.create(bundledThemes: [_def('paper'), _def('paper-hc')]));
addTearDown(hf.dispose);
await tester.pumpWidget(
harness(
hf,
SettingsView(controller: hf.services.theme, onDismiss: ([_]) {}),
),
);
await tester.pumpWidget(harness(hf, SettingsView(controller: hf.services.theme, onDismiss: ([_]) {})));
// Base theme listed once (displayName + muted name); the -hc sibling is
// folded into the toggle, not shown as a row.
expect(find.text('paper'), findsNWidgets(2));
+40 -31
View File
@@ -16,16 +16,19 @@ import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
IpcResponse _ticket(String id) => IpcResponse.ok(id: '', data: {
'id': id,
'title': 'Ticket $id',
'type': 'task',
'status': 'backlog',
'priority': 'medium',
'description': 'Body of $id',
'ancestors': <Object?>[],
'decisions': <Object?>[],
});
IpcResponse _ticket(String id) => IpcResponse.ok(
id: '',
data: {
'id': id,
'title': 'Ticket $id',
'type': 'task',
'status': 'backlog',
'priority': 'medium',
'description': 'Body of $id',
'ancestors': <Object?>[],
'decisions': <Object?>[],
},
);
void main() {
group('TicketDetailController — loads on load (T-199)', () {
@@ -61,11 +64,13 @@ void main() {
setUp(() async {
f = await KernelFixture.create();
f.services.panels.registerSlot(const SlotDefinition(id: Slots.contextPanel, position: SlotPosition.right));
f.services.arrangement.applyPreset(const LayoutPresetContribution(
id: 'test',
displayName: 'test',
slots: [LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: false)],
));
f.services.arrangement.applyPreset(
const LayoutPresetContribution(
id: 'test',
displayName: 'test',
slots: [LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: false)],
),
);
f.services.extensions.register(TicketsExtension());
await f.services.extensions.activate('builtin.tickets');
});
@@ -131,22 +136,26 @@ void main() {
testWidgets('renders parents, decisions, assignee, and applies a status change', (tester) async {
Map<String, Object?>? statusArgs;
f.ipc.stub(
'pql.tickets.show',
(args) async => IpcResponse.ok(id: '', data: {
'id': 'T-1',
'title': 'Rich ticket',
'type': 'task',
'status': 'backlog',
'priority': 'high',
'assigned_to': 'alice',
'description': 'body',
'ancestors': [
{'id': 'T-9', 'title': 'Parent epic', 'type': 'epic'},
],
'decisions': [
{'id': 'D-1', 'title': 'Decision one', 'type': 'confirmed', 'domain': 'architecture'},
],
}));
'pql.tickets.show',
(args) async => IpcResponse.ok(
id: '',
data: {
'id': 'T-1',
'title': 'Rich ticket',
'type': 'task',
'status': 'backlog',
'priority': 'high',
'assigned_to': 'alice',
'description': 'body',
'ancestors': [
{'id': 'T-9', 'title': 'Parent epic', 'type': 'epic'},
],
'decisions': [
{'id': 'D-1', 'title': 'Decision one', 'type': 'confirmed', 'domain': 'architecture'},
],
},
),
);
f.ipc.stub('pql.tickets.status', (args) async {
statusArgs = args;
return IpcResponse.ok(id: '', data: const {});
+16 -30
View File
@@ -12,13 +12,13 @@ import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
Map<String, Object?> _t(String id, String title, String status, {String? type, String? parentId}) => {
'id': id,
'title': title,
'status': status,
'type': type ?? 'task',
'priority': 'medium',
if (parentId != null) 'parent_id': parentId,
};
'id': id,
'title': title,
'status': status,
'type': type ?? 'task',
'priority': 'medium',
'parent_id': ?parentId,
};
IpcResponse _list(List<Map<String, Object?>> tickets) => IpcResponse.ok(id: '', data: {'tickets': tickets});
@@ -50,12 +50,7 @@ void main() {
});
testWidgets('renders sectioned cards from the loaded list', (tester) async {
f.ipc.stub(
'pql.tickets.list',
(_) async => _list([
_t('T-1', 'Active thing', 'in_progress', parentId: 'T-9'),
_t('T-2', 'Queued thing', 'backlog'),
]));
f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'Active thing', 'in_progress', parentId: 'T-9'), _t('T-2', 'Queued thing', 'backlog')]));
await pumpView(tester);
expect(find.textContaining('IN PROGRESS'), findsOneWidget);
@@ -68,12 +63,7 @@ void main() {
});
testWidgets('filter narrows the visible cards', (tester) async {
f.ipc.stub(
'pql.tickets.list',
(_) async => _list([
_t('T-1', 'Alpha', 'backlog'),
_t('T-2', 'Beta', 'backlog'),
]));
f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'Alpha', 'backlog'), _t('T-2', 'Beta', 'backlog')]));
await pumpView(tester);
expect(find.text('Alpha'), findsOneWidget);
@@ -115,11 +105,12 @@ void main() {
testWidgets('a load error is surfaced', (tester) async {
f.ipc.stub(
'pql.tickets.list',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
));
'pql.tickets.list',
(_) async => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
),
);
await pumpView(tester);
expect(find.text('boom'), findsOneWidget);
});
@@ -158,12 +149,7 @@ void main() {
// window — hence the 350ms pumps below.
Future<void> loadTwoTypes(WidgetTester tester) async {
f.ipc.stub(
'pql.tickets.list',
(_) async => _list([
_t('T-1', 'a bug item', 'backlog', type: 'bug'),
_t('T-2', 'a task item', 'backlog', type: 'task'),
]));
f.ipc.stub('pql.tickets.list', (_) async => _list([_t('T-1', 'a bug item', 'backlog', type: 'bug'), _t('T-2', 'a task item', 'backlog', type: 'task')]));
await pumpView(tester);
}
+10 -25
View File
@@ -16,10 +16,7 @@ import '../../helpers/widget_harness.dart';
/// Wraps [harness] in a DialogHost rooted on the fixture's dialog
/// router so kernel.dialog.show(...) calls render into the tree.
Widget _harness(KernelFixture f, Widget child) {
return harness(
f,
DialogHost(router: f.services.dialog, child: child),
);
return harness(f, DialogHost(router: f.services.dialog, child: child));
}
void main() {
@@ -43,15 +40,12 @@ void main() {
tearDown(() async => f.dispose());
testWidgets('MissingPluginException path renders the OpenProjectDialog', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('clide/window'),
(call) async {
if (call.method == 'pickDirectory') {
throw MissingPluginException();
}
return null;
},
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async {
if (call.method == 'pickDirectory') {
throw MissingPluginException();
}
return null;
});
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
@@ -67,10 +61,7 @@ void main() {
});
testWidgets('OpenProjectDialog Cancel dismisses the modal', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('clide/window'),
(call) async => throw MissingPluginException(),
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async => throw MissingPluginException());
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
@@ -85,10 +76,7 @@ void main() {
});
testWidgets('OpenProjectDialog Open with empty path is a no-op', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('clide/window'),
(call) async => throw MissingPluginException(),
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async => throw MissingPluginException());
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
@@ -105,10 +93,7 @@ void main() {
});
testWidgets('OpenProjectDialog Open with a non-repo path keeps the dialog (project.open returns false)', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('clide/window'),
(call) async => throw MissingPluginException(),
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async => throw MissingPluginException());
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
+34 -48
View File
@@ -75,12 +75,7 @@ void main() {
});
testWidgets('status line shows "application ok" when all tools resolved', (tester) async {
f.services.toolchain.applyResolved(const ResolvedPaths(
git: '/usr/bin/git',
pql: '/usr/bin/pql',
tmux: '/usr/bin/tmux',
shell: '/bin/bash',
));
f.services.toolchain.applyResolved(const ResolvedPaths(git: '/usr/bin/git', pql: '/usr/bin/pql', tmux: '/usr/bin/tmux', shell: '/bin/bash'));
await tester.pumpWidget(harness(f, const WelcomeView()));
await tester.pumpAndSettle();
expect(find.text('application ok'), findsOneWidget);
@@ -91,9 +86,7 @@ void main() {
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
f.services.toolchain.applyResolved(const ResolvedPaths(
pql: '/usr/bin/pql',
));
f.services.toolchain.applyResolved(const ResolvedPaths(pql: '/usr/bin/pql'));
await tester.pumpWidget(harness(f, const WelcomeView()));
await tester.pumpAndSettle();
expect(find.textContaining('git not found'), findsOneWidget);
@@ -102,15 +95,17 @@ void main() {
testWidgets('theme-name link fires the theme.pick command when tapped', (tester) async {
var invocations = 0;
f.services.commands.register(CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Theme: Pick',
run: (_) async {
invocations++;
return IpcResponse.ok(id: '', data: const {});
},
));
f.services.commands.register(
CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Theme: Pick',
run: (_) async {
invocations++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await tester.pumpWidget(harness(f, const WelcomeView()));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('theme:'));
@@ -140,21 +135,21 @@ void main() {
// harness()'s unbounded width breaks WelcomeView's Positioned status line
// + Flexible rows independently).
Widget tightWelcome() => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: const MediaQuery(
data: MediaQueryData(size: Size(1200, 900)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 1200, height: 900, child: WelcomeView()),
),
),
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: const MediaQuery(
data: MediaQueryData(size: Size(1200, 900)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 1200, height: 900, child: WelcomeView()),
),
),
);
),
),
);
Future<void> seedRecents(WidgetTester tester, String json) async {
// runAsync: real event loop, so SettingsStore's file I/O completes.
@@ -186,10 +181,7 @@ void main() {
});
testWidgets('sticky-startup toggle flips when tapped (T-115/T-122)', (tester) async {
await seedRecents(
tester,
'[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]',
);
await seedRecents(tester, '[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]');
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
@@ -205,10 +197,7 @@ void main() {
});
testWidgets('tapping a recent row kicks off _openRecent without throwing (T-122)', (tester) async {
await seedRecents(
tester,
'[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]',
);
await seedRecents(tester, '[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]');
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
@@ -228,15 +217,12 @@ void main() {
testWidgets('Open folder opens the fallback dialog when the picker throws MissingPluginException', (tester) async {
// Pre-register a mock that throws — emulating a platform without
// native picker support.
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('clide/window'),
(call) async {
if (call.method == 'pickDirectory') {
throw MissingPluginException();
}
return null;
},
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async {
if (call.method == 'pickDirectory') {
throw MissingPluginException();
}
return null;
});
await tester.pumpWidget(harness(f, const WelcomeView()));
await tester.pumpAndSettle();
await tester.tap(find.text('Open folder…'));
+28 -12
View File
@@ -20,9 +20,15 @@ void main() {
});
test('valid argv → parsed and re-dispatched (round-trip via ping)', () async {
final res = await d.dispatch(IpcRequest(id: 'x', cmd: argvSentinelCmd, args: {
'argv': ['ping'],
}));
final res = await d.dispatch(
IpcRequest(
id: 'x',
cmd: argvSentinelCmd,
args: {
'argv': ['ping'],
},
),
);
expect(res.ok, isTrue);
expect(res.id, 'x');
expect(res.data['pong'], isTrue);
@@ -36,26 +42,36 @@ void main() {
});
test('args.argv is not a list → userError', () async {
final res = await d.dispatch(IpcRequest(id: 'z', cmd: argvSentinelCmd, args: {
'argv': 'not a list',
}));
final res = await d.dispatch(IpcRequest(id: 'z', cmd: argvSentinelCmd, args: {'argv': 'not a list'}));
expect(res.ok, isFalse);
expect(res.error?.kind, IpcErrorKind.userError);
});
test('argv that fails parseArgv → that error flows back unmodified', () async {
final res = await d.dispatch(IpcRequest(id: 'p', cmd: argvSentinelCmd, args: {
'argv': const <String>[], // empty argv triggers parseArgv usage error
}));
final res = await d.dispatch(
IpcRequest(
id: 'p',
cmd: argvSentinelCmd,
args: {
'argv': const <String>[], // empty argv triggers parseArgv usage error
},
),
);
expect(res.ok, isFalse);
expect(res.error?.kind, IpcErrorKind.userError);
expect(res.error?.message, contains('usage'));
});
test('outer request id is preserved on the response', () async {
final res = await d.dispatch(IpcRequest(id: 'unique-id-123', cmd: argvSentinelCmd, args: {
'argv': ['ping'],
}));
final res = await d.dispatch(
IpcRequest(
id: 'unique-id-123',
cmd: argvSentinelCmd,
args: {
'argv': ['ping'],
},
),
);
expect(res.id, 'unique-id-123');
});
}
+1 -8
View File
@@ -36,14 +36,7 @@ void main() {
if (!hasCC) return;
final src = '$repoRoot/native/clide-cli/clide.c';
final out = '${Directory.systemTemp.createTempSync('clide-cli-test-').path}/clide';
final build = await Process.run('cc', [
'-std=c99',
'-O2',
'-Wall',
src,
'-o',
out,
]);
final build = await Process.run('cc', ['-std=c99', '-O2', '-Wall', src, '-o', out]);
expect(build.exitCode, 0, reason: 'cc failed: ${build.stderr}');
binaryPath = out;
+1 -5
View File
@@ -26,10 +26,6 @@ String _pubspecVersion() {
void main() {
test('clideVersion matches pubspec.yaml version (T-213)', () {
expect(
clideVersion,
_pubspecVersion(),
reason: 'build_info.g.dart drifted from pubspec.yaml — run `make gen-build-info`',
);
expect(clideVersion, _pubspecVersion(), reason: 'build_info.g.dart drifted from pubspec.yaml — run `make gen-build-info`');
});
}
+4 -1
View File
@@ -122,7 +122,10 @@ void main() {
d.register(
'git.checkout',
(req) async => IpcResponse.ok(id: req.id, data: const {}),
schema: CommandSchema(positional: const ['ref'], args: {'ref': ArgSpec(pattern: RegExp(r'^\w+$'))}),
schema: CommandSchema(
positional: const ['ref'],
args: {'ref': ArgSpec(pattern: RegExp(r'^\w+$'))},
),
);
final tools = d.mcpTools();
+4 -6
View File
@@ -138,9 +138,7 @@ void main() {
await call('editor.open', {'path': 'a.md'});
await call('editor.open', {'path': 'b.md'});
final r = await call('editor.list');
final names = [
for (final b in (r.data['buffers'] as List).cast<Map>()) b['path'],
];
final names = [for (final b in (r.data['buffers'] as List).cast<Map>()) b['path']];
expect(names, containsAll(['a.md', 'b.md']));
});
@@ -204,14 +202,14 @@ void main() {
test('editor.set-selection clamps and applies', () async {
await call('editor.open', {'path': 'doc.md'});
final r = await call('editor.set-selection', {
'selection': {'start': 0, 'end': 3}
'selection': {'start': 0, 'end': 3},
});
expect(r.ok, isTrue);
});
test('editor.set-selection without an id or active buffer returns not-found', () async {
final r = await call('editor.set-selection', {
'selection': {'start': 0, 'end': 1}
'selection': {'start': 0, 'end': 1},
});
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
@@ -226,7 +224,7 @@ void main() {
expect(read1.data['content'], 'replaced');
final r2 = await call('editor.set-content', {
'text': 'short',
'selection': {'start': 1, 'end': 99}
'selection': {'start': 1, 'end': 99},
});
expect(r2.ok, isTrue);
});
+1 -3
View File
@@ -67,9 +67,7 @@ void main() {
test('files.ls into a subdirectory returns its contents', () async {
final r = await call('files.ls', const {'path': 'lib'});
expect(r.ok, isTrue);
final names = [
for (final e in (r.data['entries'] as List).cast<Map>()) e['name'],
];
final names = [for (final e in (r.data['entries'] as List).cast<Map>()) e['name']];
expect(names, ['main.dart']);
});
+4 -4
View File
@@ -40,8 +40,8 @@ void main() {
(
'git.stage',
const {
'paths': ['file.txt']
}
'paths': ['file.txt'],
},
),
('git.stage-all', const <String, Object?>{}),
('git.unstage', const <String, Object?>{}),
@@ -53,8 +53,8 @@ void main() {
(
'git.discard',
const {
'paths': ['file.txt']
}
'paths': ['file.txt'],
},
),
('git.commit', const {'message': 'hi'}),
('git.stash', const <String, Object?>{}),
+12 -27
View File
@@ -13,23 +13,11 @@ void main() {
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-cmd-test-');
await Process.run('git', ['init'], workingDirectory: sandbox.path);
await Process.run(
'git',
['config', 'user.email', 'test@test.com'],
workingDirectory: sandbox.path,
);
await Process.run(
'git',
['config', 'user.name', 'Test'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['config', 'user.email', 'test@test.com'], workingDirectory: sandbox.path);
await Process.run('git', ['config', 'user.name', 'Test'], workingDirectory: sandbox.path);
await File('${sandbox.path}/file.txt').writeAsString('hello\n');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['commit', '-m', 'init'], workingDirectory: sandbox.path);
sink = RecordingEventSink();
dispatcher = DaemonDispatcher();
@@ -64,7 +52,7 @@ void main() {
test('git.stage + git.status shows staged file', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
final stage = await call('git.stage', {
'paths': ['new.txt']
'paths': ['new.txt'],
});
expect(stage.ok, isTrue);
@@ -82,10 +70,10 @@ void main() {
test('git.unstage removes from staging', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
await call('git.stage', {
'paths': ['new.txt']
'paths': ['new.txt'],
});
final unstage = await call('git.unstage', {
'paths': ['new.txt']
'paths': ['new.txt'],
});
expect(unstage.ok, isTrue);
@@ -97,7 +85,7 @@ void main() {
test('git.commit creates a commit', () async {
await File('${sandbox.path}/c.txt').writeAsString('x');
await call('git.stage', {
'paths': ['c.txt']
'paths': ['c.txt'],
});
final r = await call('git.commit', {'message': 'test commit'});
expect(r.ok, isTrue);
@@ -121,7 +109,7 @@ void main() {
test('git.diff --staged returns staged diffs', () async {
await File('${sandbox.path}/file.txt').writeAsString('modified\n');
await call('git.stage', {
'paths': ['file.txt']
'paths': ['file.txt'],
});
final r = await call('git.diff', {'staged': true});
expect(r.ok, isTrue);
@@ -139,7 +127,7 @@ void main() {
test('git.discard restores a file', () async {
await File('${sandbox.path}/file.txt').writeAsString('changed');
final r = await call('git.discard', {
'paths': ['file.txt']
'paths': ['file.txt'],
});
expect(r.ok, isTrue);
final content = await File('${sandbox.path}/file.txt').readAsString();
@@ -155,12 +143,9 @@ void main() {
test('mutations emit git.changed events', () async {
await File('${sandbox.path}/e.txt').writeAsString('x');
await call('git.stage', {
'paths': ['e.txt']
'paths': ['e.txt'],
});
expect(
sink.events,
contains(predicate<IpcEvent>((e) => e.kind == 'git.changed')),
);
expect(sink.events, contains(predicate<IpcEvent>((e) => e.kind == 'git.changed')));
});
test('git.stage-all stages everything', () async {
@@ -192,7 +177,7 @@ void main() {
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
await File('${sandbox.path}/other.txt').writeAsString('o');
final r = await call('git.diff', {
'paths': ['file.txt']
'paths': ['file.txt'],
});
expect(r.ok, isTrue);
final diffs = r.data['diffs'] as List;
+6 -11
View File
@@ -17,19 +17,14 @@ void main() {
void wire({bool liveUi = true, Set<String> found = const {'docs/diagram.png'}, bool withResolver = true}) {
published = [];
d = DaemonDispatcher();
registerImageCommands(
d,
() {
if (!liveUi) return null;
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
},
resolve: withResolver ? (path) => found.contains(path) ? '/abs/$path' : null : null,
);
registerImageCommands(d, () {
if (!liveUi) return null;
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
}, resolve: withResolver ? (path) => found.contains(path) ? '/abs/$path' : null : null);
}
Future<IpcResponse> show(List<String> positional, {Map<String, Object?>? flags}) => d.dispatch(
IpcRequest(id: '1', cmd: 'image.show', args: {'positional': positional, if (flags != null) 'flags': flags}),
);
Future<IpcResponse> show(List<String> positional, {Map<String, Object?>? flags}) =>
d.dispatch(IpcRequest(id: '1', cmd: 'image.show', args: {'positional': positional, 'flags': ?flags}));
test('resolves a workspace-relative path and publishes an image message', () async {
wire();
+3 -6
View File
@@ -75,10 +75,7 @@ void main() {
expect(viaText.ok, isTrue);
expect(viaText.data['written'], greaterThan(0));
final viaBase64 = await call('pane.write', {
'id': id,
'bytes_b64': base64Encode(utf8.encode('def')),
});
final viaBase64 = await call('pane.write', {'id': id, 'bytes_b64': base64Encode(utf8.encode('def'))});
expect(viaBase64.ok, isTrue);
});
@@ -115,7 +112,7 @@ void main() {
test('pane.spawn rejects non-string argv entries', () async {
final r = await call('pane.spawn', const {
'argv': ['/bin/sh', 42]
'argv': ['/bin/sh', 42],
});
expect(r.ok, isFalse);
expect(r.error!.message, contains('strings'));
@@ -262,7 +259,7 @@ void main() {
test('merges PTY panes and UI tabs in one list', () async {
await call('pane.spawn', {
'argv': const ['/bin/cat']
'argv': const ['/bin/cat'],
});
final r = await call('pane.list', const {});
final panes = (r.data['panes'] as List).cast<Map>();
+1 -4
View File
@@ -17,10 +17,7 @@ void main() {
late _FakeResizer resizer;
setUp(() {
resizer = _FakeResizer(
slots: {'sidebar': 200, 'context': 240, 'workspace': 800},
editorRatio: 0.35,
);
resizer = _FakeResizer(slots: {'sidebar': 200, 'context': 240, 'workspace': 800}, editorRatio: 0.35);
dispatcher = DaemonDispatcher();
registerPanelCommands(dispatcher, resizer);
});
+1 -1
View File
@@ -49,7 +49,7 @@ void main() {
const {
'ids': ['T-1'],
'status': 'done',
}
},
),
('pql.tickets.board', const <String, Object?>{}),
('pql.plan.status', const <String, Object?>{}),
+3 -16
View File
@@ -181,11 +181,7 @@ void main() {
});
test('pql.decisions.show forwards withRefs / withTickets', () async {
final r = await call('pql.decisions.show', {
'id': 'D-1',
'withRefs': true,
'withTickets': true,
});
final r = await call('pql.decisions.show', {'id': 'D-1', 'withRefs': true, 'withTickets': true});
expect(r.ok, isTrue);
expect(r.data['id'], 'D-1');
});
@@ -198,12 +194,7 @@ void main() {
});
test('pql.tickets.list with filters narrows + shows status', () async {
final r = await call('pql.tickets.list', {
'status': 'done',
'team': 'whatever',
'assigned': 'no-one',
'decision': 'D-1',
});
final r = await call('pql.tickets.list', {'status': 'done', 'team': 'whatever', 'assigned': 'no-one', 'decision': 'D-1'});
expect(r.ok, isTrue);
expect(r.data['tickets'], isA<List>());
});
@@ -212,11 +203,7 @@ void main() {
final missing = await call('pql.tickets.show');
expect(missing.ok, isFalse);
expect(missing.error!.kind, 'user_error');
final ok = await call('pql.tickets.show', {
'id': 'T-1',
'withContext': true,
'withBlockers': true,
});
final ok = await call('pql.tickets.show', {'id': 'T-1', 'withContext': true, 'withBlockers': true});
expect(ok.ok, isTrue);
});
+1 -6
View File
@@ -17,12 +17,7 @@ void main() {
File('${dir.path}/a.dart').writeAsStringSync('final answer = 42;\n');
File('${dir.path}/b.dart').writeAsStringSync('// no hits here\n');
sink = RecordingEventSink();
final service = SearchService(
root: dir,
ignore: IgnoreSet([]),
events: sink,
useIsolates: false,
);
final service = SearchService(root: dir, ignore: IgnoreSet([]), events: sink, useIsolates: false);
d = DaemonDispatcher();
registerSearchCommands(d, service);
});
+1 -7
View File
@@ -13,13 +13,7 @@ void main() {
test('status resolves and returns the assembled snapshot with exit 0', () async {
final d = DaemonDispatcher();
registerStatusCommand(
d,
() async => {
'workspace': '/repo',
'focusedFile': null,
'panes': const [],
});
registerStatusCommand(d, () async => {'workspace': '/repo', 'focusedFile': null, 'panes': const []});
final r = await d.dispatch(req());
expect(r.ok, isTrue);
expect(r.data['workspace'], '/repo');
+8 -17
View File
@@ -20,19 +20,13 @@ void main() {
published = [];
filterValues = {};
d = DaemonDispatcher();
registerUiCommands(
d,
() {
if (!liveUi) return null;
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
},
filterValue: liveUi ? (address) => filterValues[address] : null,
);
registerUiCommands(d, () {
if (!liveUi) return null;
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
}, filterValue: liveUi ? (address) => filterValues[address] : null);
}
Future<IpcResponse> open(List<String> positional) => d.dispatch(
IpcRequest(id: '1', cmd: 'ui.open', args: {'positional': positional}),
);
Future<IpcResponse> open(List<String> positional) => d.dispatch(IpcRequest(id: '1', cmd: 'ui.open', args: {'positional': positional}));
test('ui open tickets T-48 publishes a tickets selection', () async {
wire();
@@ -97,9 +91,8 @@ void main() {
// -- ui.toast (T-50 drive-half) -------------------------------------------
Future<IpcResponse> toast(List<String> positional, {Map<String, Object?>? flags}) => d.dispatch(
IpcRequest(id: '1', cmd: 'ui.toast', args: {'positional': positional, if (flags != null) 'flags': flags}),
);
Future<IpcResponse> toast(List<String> positional, {Map<String, Object?>? flags}) =>
d.dispatch(IpcRequest(id: '1', cmd: 'ui.toast', args: {'positional': positional, 'flags': ?flags}));
test('ui toast publishes a toast message (default info severity)', () async {
wire();
@@ -150,9 +143,7 @@ void main() {
// -- ui.filter (T-270 drive+observe half) ---------------------------------
Future<IpcResponse> filter(List<String> positional) => d.dispatch(
IpcRequest(id: '1', cmd: 'ui.filter', args: {'positional': positional}),
);
Future<IpcResponse> filter(List<String> positional) => d.dispatch(IpcRequest(id: '1', cmd: 'ui.filter', args: {'positional': positional}));
test('ui filter <address> <text> publishes a filter.set', () async {
wire();
+1 -4
View File
@@ -138,10 +138,7 @@ void main() {
test('contentFromArgs decodes content_b64 when text is absent', () {
expect(EditorRegistry.contentFromArgs({'text': 'plain'}), 'plain');
expect(
EditorRegistry.contentFromArgs({'content_b64': 'aGVsbG8='}),
'hello',
);
expect(EditorRegistry.contentFromArgs({'content_b64': 'aGVsbG8='}), 'hello');
expect(EditorRegistry.contentFromArgs(const {}), '');
});
+3 -14
View File
@@ -25,10 +25,7 @@ void main() {
});
test('StatusItemContribution pins the statusbar slot', () {
final s = StatusItemContribution(
id: 'ipc-status.indicator',
build: (_) => const SizedBox.shrink(),
);
final s = StatusItemContribution(id: 'ipc-status.indicator', build: (_) => const SizedBox.shrink());
expect(s.slot, Slots.statusbar);
});
@@ -42,20 +39,12 @@ void main() {
});
test('TrayItemContribution pins the tray slot', () {
final t = TrayItemContribution(
id: 't',
label: 'Label',
onSelected: () {},
);
final t = TrayItemContribution(id: 't', label: 'Label', onSelected: () {});
expect(t.slot, Slots.tray);
});
test('ToolbarButtonContribution pins the toolbar slot', () {
final b = ToolbarButtonContribution(
id: 'save',
label: 'Save',
onPressed: () {},
);
final b = ToolbarButtonContribution(id: 'save', label: 'Save', onPressed: () {});
expect(b.slot, Slots.toolbar);
});
});
+13 -9
View File
@@ -53,11 +53,13 @@ void main() {
test('publish + subscribe round-trip through the message bus', () async {
Message? received;
f.services.extensions.register(_SugarExt((ctx) async {
final first = ctx.subscribe(channel: 'greet').first;
ctx.publish('greet', {'hello': 'world'});
received = await first;
}));
f.services.extensions.register(
_SugarExt((ctx) async {
final first = ctx.subscribe(channel: 'greet').first;
ctx.publish('greet', {'hello': 'world'});
received = await first;
}),
);
await f.services.extensions.activate('sugar.ext');
expect(received, isNotNull);
expect(received!.publisher, 'sugar.ext');
@@ -68,10 +70,12 @@ void main() {
test('t + tr resolve against the extension namespace', () async {
String? t;
String? tr;
f.services.extensions.register(_SugarExt((ctx) async {
t = ctx.t('missing.key');
tr = ctx.tr('missing.key', replacers: const []);
}));
f.services.extensions.register(
_SugarExt((ctx) async {
t = ctx.t('missing.key');
tr = ctx.tr('missing.key', replacers: const []);
}),
);
await f.services.extensions.activate('sugar.ext');
// No catalog loaded for this namespace → i18n falls back, but the
// sugar getters still execute and return a non-null string.
+3 -12
View File
@@ -46,24 +46,15 @@ depends_on: [builtin.git, 42, builtin.diff]
});
test('missing id throws FormatException', () {
expect(
() => ExtensionManifest.fromYamlString('title: Floating'),
throwsA(isA<FormatException>()),
);
expect(() => ExtensionManifest.fromYamlString('title: Floating'), throwsA(isA<FormatException>()));
});
test('empty id throws FormatException', () {
expect(
() => ExtensionManifest.fromYamlString('id: ""'),
throwsA(isA<FormatException>()),
);
expect(() => ExtensionManifest.fromYamlString('id: ""'), throwsA(isA<FormatException>()));
});
test('non-map root throws FormatException', () {
expect(
() => ExtensionManifest.fromYamlString('- just a list'),
throwsA(isA<FormatException>()),
);
expect(() => ExtensionManifest.fromYamlString('- just a list'), throwsA(isA<FormatException>()));
});
});
}
+1 -4
View File
@@ -69,10 +69,7 @@ void main() {
test('** crosses directory boundaries', () {
final s = IgnoreSet.parse(const ['docs/**/*.draft.md\n']);
expect(
s.isIgnored('docs/deep/nested/a.draft.md', isDirectory: false),
isTrue,
);
expect(s.isIgnored('docs/deep/nested/a.draft.md', isDirectory: false), isTrue);
expect(s.isIgnored('docs/a.draft.md', isDirectory: false), isTrue);
expect(s.isIgnored('other/a.draft.md', isDirectory: false), isFalse);
});
+3 -12
View File
@@ -104,10 +104,7 @@ void main() {
final link = Link('${root.path}/leak')..createSync(secret.path);
expect(link.existsSync(), isTrue);
expect(
() => resolveUnderRootFollowingSymlinks(root, 'leak'),
throwsA(isA<PathOutsideRoot>()),
);
expect(() => resolveUnderRootFollowingSymlinks(root, 'leak'), throwsA(isA<PathOutsideRoot>()));
});
test('tolerates symlinks in the workspace root path itself', () {
@@ -130,10 +127,7 @@ void main() {
Link('${root.path}/b').createSync(secret.path);
Link('${root.path}/a').createSync('${root.path}/b');
expect(
() => resolveUnderRootFollowingSymlinks(root, 'a'),
throwsA(isA<PathOutsideRoot>()),
);
expect(() => resolveUnderRootFollowingSymlinks(root, 'a'), throwsA(isA<PathOutsideRoot>()));
});
});
@@ -171,10 +165,7 @@ void main() {
addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null);
File('${outside.path}/secret.txt').writeAsStringSync('payload');
Link('${extra.path}/leak').createSync('${outside.path}/secret.txt');
expect(
() => resolveUnderRootsFollowingSymlinks(root, [extra], '${extra.absolute.path}/leak'),
throwsA(isA<PathOutsideRoot>()),
);
expect(() => resolveUnderRootsFollowingSymlinks(root, [extra], '${extra.absolute.path}/leak'), throwsA(isA<PathOutsideRoot>()));
});
});
}
+1 -7
View File
@@ -28,13 +28,7 @@ void main() {
test('walks recursively, returns files sorted by path', () async {
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
expect(r.truncated, isFalse);
expect(r.files.map((e) => e.path).toList(), [
'README.md',
'build/output.bin',
'lib/main.dart',
'lib/src/util.dart',
'pubspec.yaml',
]);
expect(r.files.map((e) => e.path).toList(), ['README.md', 'build/output.bin', 'lib/main.dart', 'lib/src/util.dart', 'pubspec.yaml']);
});
test('prunes ignored directories (build/ via builtin ignore)', () async {
+8 -14
View File
@@ -15,12 +15,12 @@ void main() {
test('fromEvent maps every FileSystemEvent.type to a wire kind', () {
final dir = Directory.systemTemp;
FileSystemEvent ev(int type) => switch (type) {
FileSystemEvent.create => FileSystemCreateEvent('${dir.path}/f', false),
FileSystemEvent.delete => FileSystemDeleteEvent('${dir.path}/f', false),
FileSystemEvent.modify => FileSystemModifyEvent('${dir.path}/f', false, false),
FileSystemEvent.move => FileSystemMoveEvent('${dir.path}/f', false, '${dir.path}/g'),
_ => FileSystemModifyEvent('${dir.path}/f', false, false),
};
FileSystemEvent.create => FileSystemCreateEvent('${dir.path}/f', false),
FileSystemEvent.delete => FileSystemDeleteEvent('${dir.path}/f', false),
FileSystemEvent.modify => FileSystemModifyEvent('${dir.path}/f', false, false),
FileSystemEvent.move => FileSystemMoveEvent('${dir.path}/f', false, '${dir.path}/g'),
_ => FileSystemModifyEvent('${dir.path}/f', false, false),
};
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.create)), FileChangeKind.created);
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.delete)), FileChangeKind.deleted);
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.modify)), FileChangeKind.modified);
@@ -65,15 +65,9 @@ void main() {
// amount. firstWhere completes on the first matching event;
// the timeout fails the test with a clear message if inotify
// never delivers (instead of asserting on an empty list).
final saw = watcher.stream.firstWhere(
(c) => c.path == 'new.txt',
orElse: () => throw StateError('stream closed before new.txt arrived'),
);
final saw = watcher.stream.firstWhere((c) => c.path == 'new.txt', orElse: () => throw StateError('stream closed before new.txt arrived'));
await File('${sandbox.path}/new.txt').writeAsString('hi');
final change = await saw.timeout(
ioTimeout,
onTimeout: () => fail('no `new.txt` event within ${ioTimeout.inSeconds}s'),
);
final change = await saw.timeout(ioTimeout, onTimeout: () => fail('no `new.txt` event within ${ioTimeout.inSeconds}s'));
expect(change.path, 'new.txt');
});
+4 -20
View File
@@ -161,23 +161,11 @@ index abc..def 100644
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-diff-test-');
await Process.run('git', ['init'], workingDirectory: sandbox.path);
await Process.run(
'git',
['config', 'user.email', 'test@test.com'],
workingDirectory: sandbox.path,
);
await Process.run(
'git',
['config', 'user.name', 'Test'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['config', 'user.email', 'test@test.com'], workingDirectory: sandbox.path);
await Process.run('git', ['config', 'user.name', 'Test'], workingDirectory: sandbox.path);
await File('${sandbox.path}/file.txt').writeAsString('line1\nline2\n');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['commit', '-m', 'init'], workingDirectory: sandbox.path);
});
tearDown(() async {
@@ -194,11 +182,7 @@ index abc..def 100644
test('returns staged diff with staged: true', () async {
await File('${sandbox.path}/file.txt').writeAsString('line1\nmodified\n');
await Process.run(
'git',
['add', 'file.txt'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['add', 'file.txt'], workingDirectory: sandbox.path);
final diffs = await gitDiff(sandbox, staged: true);
expect(diffs, hasLength(1));
});
+13 -69
View File
@@ -9,23 +9,11 @@ void main() {
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-ops-test-');
await Process.run('git', ['init'], workingDirectory: sandbox.path);
await Process.run(
'git',
['config', 'user.email', 'test@test.com'],
workingDirectory: sandbox.path,
);
await Process.run(
'git',
['config', 'user.name', 'Test'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['config', 'user.email', 'test@test.com'], workingDirectory: sandbox.path);
await Process.run('git', ['config', 'user.name', 'Test'], workingDirectory: sandbox.path);
await File('${sandbox.path}/file.txt').writeAsString('hello\n');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['commit', '-m', 'init'], workingDirectory: sandbox.path);
});
tearDown(() async {
@@ -35,11 +23,7 @@ void main() {
test('gitStage stages a file', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
await gitStage(sandbox, ['new.txt']);
final r = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
final r = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
expect((r.stdout as String).trim(), 'new.txt');
});
@@ -47,11 +31,7 @@ void main() {
await File('${sandbox.path}/new.txt').writeAsString('x');
await gitStage(sandbox, ['new.txt']);
await gitUnstage(sandbox, ['new.txt']);
final r = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
final r = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
expect((r.stdout as String).trim(), isEmpty);
});
@@ -60,19 +40,12 @@ void main() {
await gitStage(sandbox, ['c.txt']);
final hash = await gitCommit(sandbox, 'test commit');
expect(hash, hasLength(40));
final r = await Process.run(
'git',
['log', '-1', '--format=%s'],
workingDirectory: sandbox.path,
);
final r = await Process.run('git', ['log', '-1', '--format=%s'], workingDirectory: sandbox.path);
expect((r.stdout as String).trim(), 'test commit');
});
test('gitCommit with nothing staged throws', () async {
expect(
() => gitCommit(sandbox, 'empty'),
throwsA(isA<GitException>()),
);
expect(() => gitCommit(sandbox, 'empty'), throwsA(isA<GitException>()));
});
test('gitLog returns entries', () async {
@@ -111,22 +84,9 @@ void main() {
});
test('GitLogEntry.toJson serialises every field (body omitted when empty)', () {
const a = GitLogEntry(
hash: 'h',
shortHash: 's',
subject: 'sub',
author: 'a',
date: 'd',
);
const a = GitLogEntry(hash: 'h', shortHash: 's', subject: 'sub', author: 'a', date: 'd');
expect(a.toJson().containsKey('body'), isFalse);
const b = GitLogEntry(
hash: 'h',
shortHash: 's',
subject: 'sub',
author: 'a',
date: 'd',
body: 'bd',
);
const b = GitLogEntry(hash: 'h', shortHash: 's', subject: 'sub', author: 'a', date: 'd', body: 'bd');
expect(b.toJson()['body'], 'bd');
});
@@ -142,35 +102,19 @@ void main() {
await File('${sandbox.path}/b.txt').writeAsString('y');
await gitStage(sandbox, ['a.txt', 'b.txt']);
await gitUnstage(sandbox, const []);
final r = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
final r = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
expect((r.stdout as String).trim(), isEmpty);
});
test('gitStageHunk + gitUnstageHunk apply a patch via _applyPatch', () async {
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
final patchResult = await Process.run(
'git',
['diff', '-U0'],
workingDirectory: sandbox.path,
);
final patchResult = await Process.run('git', ['diff', '-U0'], workingDirectory: sandbox.path);
final patch = patchResult.stdout as String;
await gitStageHunk(sandbox, patch);
final cached = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
final cached = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
expect((cached.stdout as String).trim(), 'file.txt');
await gitUnstageHunk(sandbox, patch);
final cleared = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
final cleared = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
expect((cleared.stdout as String).trim(), isEmpty);
});
+5 -25
View File
@@ -9,24 +9,12 @@ void main() {
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-status-test-');
await Process.run('git', ['init'], workingDirectory: sandbox.path);
await Process.run(
'git',
['config', 'user.email', 'test@test.com'],
workingDirectory: sandbox.path,
);
await Process.run(
'git',
['config', 'user.name', 'Test'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['config', 'user.email', 'test@test.com'], workingDirectory: sandbox.path);
await Process.run('git', ['config', 'user.name', 'Test'], workingDirectory: sandbox.path);
// Initial commit so HEAD exists.
await File('${sandbox.path}/.gitkeep').writeAsString('');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['commit', '-m', 'init'], workingDirectory: sandbox.path);
});
tearDown(() async {
@@ -48,11 +36,7 @@ void main() {
test('staged file appears in staged', () async {
await File('${sandbox.path}/staged.txt').writeAsString('x');
await Process.run(
'git',
['add', 'staged.txt'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['add', 'staged.txt'], workingDirectory: sandbox.path);
final status = await gitStatus(sandbox);
expect(status.staged, hasLength(1));
expect(status.staged.first.path, 'staged.txt');
@@ -75,11 +59,7 @@ void main() {
test('file staged and then modified appears in both', () async {
await File('${sandbox.path}/both.txt').writeAsString('v1');
await Process.run(
'git',
['add', 'both.txt'],
workingDirectory: sandbox.path,
);
await Process.run('git', ['add', 'both.txt'], workingDirectory: sandbox.path);
await File('${sandbox.path}/both.txt').writeAsString('v2');
final status = await gitStatus(sandbox);
expect(status.staged.any((e) => e.path == 'both.txt'), isTrue);
+5 -36
View File
@@ -32,54 +32,23 @@ void main() {
),
GoldenTestScenario(
name: 'primary / enabled',
child: _wrap(
f,
ClideButton(
label: 'Commit',
onPressed: () {},
variant: ClideButtonVariant.primary,
),
),
child: _wrap(f, ClideButton(label: 'Commit', onPressed: () {}, variant: ClideButtonVariant.primary)),
),
GoldenTestScenario(
name: 'primary / disabled',
child: _wrap(
f,
const ClideButton(
label: 'Commit',
onPressed: null,
variant: ClideButtonVariant.primary,
),
),
child: _wrap(f, const ClideButton(label: 'Commit', onPressed: null, variant: ClideButtonVariant.primary)),
),
GoldenTestScenario(
name: 'subtle / enabled',
child: _wrap(
f,
ClideButton(
label: 'Open',
onPressed: () {},
variant: ClideButtonVariant.subtle,
),
),
child: _wrap(f, ClideButton(label: 'Open', onPressed: () {}, variant: ClideButtonVariant.subtle)),
),
GoldenTestScenario(
name: 'subtle / disabled',
child: _wrap(
f,
const ClideButton(
label: 'Open',
onPressed: null,
variant: ClideButtonVariant.subtle,
),
),
child: _wrap(f, const ClideButton(label: 'Open', onPressed: null, variant: ClideButtonVariant.subtle)),
),
],
),
);
}
Widget _wrap(KernelFixture f, Widget child) => SizedBox(
width: 140,
child: harness(f, child),
);
Widget _wrap(KernelFixture f, Widget child) => SizedBox(width: 140, child: harness(f, child));
+1 -8
View File
@@ -29,14 +29,7 @@ void main() {
])
GoldenTestScenario(
name: pair.$1,
child: harness(
f,
SizedBox(
width: 24,
height: 24,
child: ClideIcon(pair.$2, size: 24),
),
),
child: harness(f, SizedBox(width: 24, height: 24, child: ClideIcon(pair.$2, size: 24))),
),
],
),
+50 -38
View File
@@ -15,23 +15,29 @@ void main() {
// A stand-in inner item card: content + its own per-item status mark (the
// collapser carries the aggregate; items keep their own — T-305).
Widget item(String label, ClideRunStatus status) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF393E48)),
borderRadius: BorderRadius.circular(3),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF6A7280)), textDirection: TextDirection.ltr)),
ClideStatusIndicator(status: status, size: 12),
],
padding: const EdgeInsets.only(bottom: 10),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF393E48)),
borderRadius: BorderRadius.circular(3),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontSize: 11, color: Color(0xFF6A7280)),
textDirection: TextDirection.ltr,
),
),
),
ClideStatusIndicator(status: status, size: 12),
],
),
);
),
),
);
goldenTest(
'ClideCollapserCard (T-305): collapsed color variants + expanded inner canvas',
@@ -42,37 +48,43 @@ void main() {
GoldenTestScenario(
name: 'collapsed — default (muted)',
child: _wrap(
f,
const ClideCollapserCard(
label: 'Activity',
collapsedSummary: 'Read conversation_view.dart',
counter: '3 steps',
status: ClideRunStatus.success,
children: [SizedBox.shrink()])),
f,
const ClideCollapserCard(
label: 'Activity',
collapsedSummary: 'Read conversation_view.dart',
counter: '3 steps',
status: ClideRunStatus.success,
children: [SizedBox.shrink()],
),
),
),
GoldenTestScenario(
name: 'collapsed — edits (teal color)',
child: _wrap(
f,
const ClideCollapserCard(
label: 'Edits',
color: Color(0xFF00AB9A),
collapsedSummary: 'clide_markdown.dart',
counter: '7 edits',
status: ClideRunStatus.success,
children: [SizedBox.shrink()])),
f,
const ClideCollapserCard(
label: 'Edits',
color: Color(0xFF00AB9A),
collapsedSummary: 'clide_markdown.dart',
counter: '7 edits',
status: ClideRunStatus.success,
children: [SizedBox.shrink()],
),
),
),
GoldenTestScenario(
name: 'collapsed — error (red color)',
child: _wrap(
f,
const ClideCollapserCard(
label: 'Bash',
color: Color(0xFFF06C6F),
collapsedSummary: 'npm test',
counter: '1 step',
status: ClideRunStatus.error,
children: [SizedBox.shrink()])),
f,
const ClideCollapserCard(
label: 'Bash',
color: Color(0xFFF06C6F),
collapsedSummary: 'npm test',
counter: '1 step',
status: ClideRunStatus.error,
children: [SizedBox.shrink()],
),
),
),
GoldenTestScenario(
name: 'expanded — inner canvas of item cards',
@@ -64,7 +64,12 @@ void main() {
collapsedByDefault: true,
collapsedSummary: '/lib/main.dart',
body: Text('/lib/main.dart', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('void main() {}', textDirection: TextDirection.ltr))],
extraSegments: [
CardSegment(
label: 'result',
child: Text('void main() {}', textDirection: TextDirection.ltr),
),
],
),
),
),
@@ -80,7 +85,12 @@ void main() {
collapsible: true,
collapsedByDefault: false,
body: Text('/lib/main.dart', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('void main() {}', textDirection: TextDirection.ltr))],
extraSegments: [
CardSegment(
label: 'result',
child: Text('void main() {}', textDirection: TextDirection.ltr),
),
],
),
),
),
@@ -97,8 +107,14 @@ void main() {
collapsedByDefault: false,
body: Text('{ "description": "explore the codebase" }', textDirection: TextDirection.ltr),
extraSegments: [
CardSegment(label: 'prompt', child: Text('find all the widgets and summarise', textDirection: TextDirection.ltr)),
CardSegment(label: 'result', child: Text('found 42 widgets', textDirection: TextDirection.ltr)),
CardSegment(
label: 'prompt',
child: Text('find all the widgets and summarise', textDirection: TextDirection.ltr),
),
CardSegment(
label: 'result',
child: Text('found 42 widgets', textDirection: TextDirection.ltr),
),
],
),
),
@@ -178,7 +194,4 @@ void main() {
);
}
Widget _wrap(KernelFixture f, Widget child) => SizedBox(
width: 360,
child: harness(f, child),
);
Widget _wrap(KernelFixture f, Widget child) => SizedBox(width: 360, child: harness(f, child));
+1 -4
View File
@@ -5,8 +5,5 @@ import 'package:alchemist/alchemist.dart';
import '../helpers/golden_harness.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
return AlchemistConfig.runWithConfig(
config: clideGoldenConfig(),
run: testMain,
);
return AlchemistConfig.runWithConfig(config: clideGoldenConfig(), run: testMain);
}
+20 -10
View File
@@ -17,16 +17,26 @@ void main() {
tearDown(() async => f.dispose());
Widget bashContent() => const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text('npm test', style: TextStyle(fontSize: 12, color: Color(0xFF9DA5B4)), textDirection: TextDirection.ltr),
extraSegments: [
CardSegment(
label: 'result', child: Text('All 42 tests passed', style: TextStyle(fontSize: 12, color: Color(0xFF9DA5B4)), textDirection: TextDirection.ltr)),
],
);
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text(
'npm test',
style: TextStyle(fontSize: 12, color: Color(0xFF9DA5B4)),
textDirection: TextDirection.ltr,
),
extraSegments: [
CardSegment(
label: 'result',
child: Text(
'All 42 tests passed',
style: TextStyle(fontSize: 12, color: Color(0xFF9DA5B4)),
textDirection: TextDirection.ltr,
),
),
],
);
goldenTest(
'single tool collapser (T-305): collapsed ticker + expanded inner card',
+3 -13
View File
@@ -24,19 +24,12 @@ class FakeDaemonClient extends DaemonClient {
}
@override
Future<IpcResponse> request(
String cmd, {
Map<String, Object?> args = const {},
}) async {
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) async {
final stub = _stubs[cmd];
if (stub != null) return stub(args);
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no stub for $cmd',
),
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no stub for $cmd'),
);
}
@@ -46,10 +39,7 @@ class FakeDaemonClient extends DaemonClient {
notifyListeners();
}
void stub(
String cmd,
Future<IpcResponse> Function(Map<String, Object?>) handler,
) {
void stub(String cmd, Future<IpcResponse> Function(Map<String, Object?>) handler) {
_stubs[cmd] = handler;
}
}
+2 -6
View File
@@ -9,11 +9,7 @@ import 'package:alchemist/alchemist.dart';
AlchemistConfig clideGoldenConfig() {
return const AlchemistConfig(
theme: null, // we're not using Material ThemeData
platformGoldensConfig: PlatformGoldensConfig(
enabled: true,
),
ciGoldensConfig: CiGoldensConfig(
enabled: false,
),
platformGoldensConfig: PlatformGoldensConfig(enabled: true),
ciGoldensConfig: CiGoldensConfig(enabled: false),
);
}
+3 -12
View File
@@ -34,7 +34,7 @@ class KernelFixture {
preloadNamespaces: preloadNamespaces ?? catalogs.keys.toList(),
defaultLocale: defaultLocale,
initialLocale: initialLocale,
daemonClientFactory: (log, events, _, __) {
daemonClientFactory: (log, events, _, _) {
fake = FakeDaemonClient(log: log, events: events);
return fake!;
},
@@ -45,11 +45,7 @@ class KernelFixture {
// ~10 minutes (T-280); `existsSync` opens no native port, so it's safe.
onValidateProject: onValidateProject ?? _walkForGitRoot,
);
return KernelFixture._(
services: services,
ipc: fake!,
tempDir: tempDir,
);
return KernelFixture._(services: services, ipc: fake!, tempDir: tempDir);
}
Future<void> dispose() async {
@@ -100,10 +96,5 @@ ThemeDefinition _miniTheme() {
'error': Color(0xFFF06C6F),
'info': Color(0xFF00A3D2),
};
return const ThemeDefinition(
name: 'test',
displayName: 'Test',
dark: true,
palette: Palette(palette),
);
return const ThemeDefinition(name: 'test', displayName: 'Test', dark: true, palette: Palette(palette));
}

Some files were not shown because too many files have changed in this diff Show More