Merge main into windows-support

Brings windows-support up to date with main (T-404/405/406, T-413–416,
T-421, the T-422 workspace-lifecycle epic, and the 2.4.0 release).

Conflict resolutions:
- terminal_pane.dart: keep the Windows PowerShell shell selection and
  main's workspace-cwd fix (T-381) together.
- tool_check.dart: accept main's deletion (dead, unreferenced code).
- CHANGELOG.md: keep both Unreleased sections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 18:21:41 +02:00
co-authored by Claude Opus 4.8
152 changed files with 13684 additions and 4364 deletions
+90
View File
@@ -236,6 +236,58 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('ctrl+w o fires a window command via the global matcher, not editor.close (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
expect(f.services.arrangement.isInFocusMode, isFalse);
// ctrl+w (chord) then a BARE o → panel.focusMode. The second chord is
// consumed at the hardware level, so a focused pane can't swallow it.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isTrue);
});
testWidgets('bare ctrl+w closes the editor after the ambiguity timeout (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
expect(f.services.arrangement.editorOpen, isTrue);
// ctrl+w with no completing chord: pends, then the timeout flushes the
// exact bare-ctrl+w binding (editor.close from the contributions layer).
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump(const Duration(milliseconds: 450));
expect(f.services.arrangement.editorOpen, isFalse);
});
testWidgets('a bare-key sequence prefix (g) is not grabbed by the global matcher (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
// `g` is a prefix (gg) but bare → editor/pane-local. The global matcher must
// NOT consume it or fire a window command; the editor stays open.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isFalse);
expect(f.services.arrangement.editorOpen, isTrue);
});
testWidgets('window control buttons render and tap as no-ops in tests', (tester) async {
await pumpApp(tester);
// _RightHatContent renders ClideTappable window buttons on non-macOS;
@@ -354,6 +406,44 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('double-tapped bare Shift opens quick-open (T-341)', (tester) async {
await pumpApp(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(f.services.quickOpen.isOpen, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('typing colons (Shift+;) never triggers quick-open (T-409)', (tester) async {
await pumpApp(tester);
// Two rapid `:` keystrokes — the chorded `;` dirties each Shift press.
for (var i = 0; i < 2; i++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.semicolon);
await tester.sendKeyUpEvent(LogicalKeyboardKey.semicolon);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
}
await tester.pump();
expect(f.services.quickOpen.isOpen, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets('a bare Shift tap followed by a Shift chord does not fire (T-409)', (tester) async {
await pumpApp(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); // clean tap arms
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.semicolon); // chord — old code fired on the down
await tester.sendKeyUpEvent(LogicalKeyboardKey.semicolon);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(f.services.quickOpen.isOpen, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets('file.closeWorkspace command closes the active project', (tester) async {
final repo = Directory.current.path;
await tester.runAsync(() async => f.services.project.open(repo));
@@ -76,6 +76,16 @@ void main() {
expect(groupConversation(const [], FoldLevel.tools), isEmpty);
});
test('a Workflow run stays first-class even at L3, never folded (T-416)', () {
// At every fold level the Workflow tool-use owns its own card so the live
// run card can render — it must not fold into a generic Activity cluster.
for (final level in FoldLevel.values) {
final groups = groupConversation([_tool('1', 'Workflow'), _result('1')], level);
expect(groups.first, isA<StickyItem>(), reason: '$level');
expect((groups.first as StickyItem).item, isA<AssistantToolUse>(), reason: '$level');
}
});
test('an image card stays first-class even at L3 (everything)', () {
final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png');
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything);
@@ -0,0 +1,62 @@
/// Direct tests for ActivityTabView (T-415): the USAGE block renders parsed
/// /usage values; the empty state still shows under the control strip.
library;
import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage;
import 'package:clide/builtin/claude/src/meta_sidebar/activity_tab.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('renders the USAGE block from parsed /usage values', (tester) async {
const usage = ClaudeUsage(session: '15% used · resets Jun 12, 3:39pm', week: '53% used · resets Jun 15, 6:59pm', weekSonnet: '0% used');
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null, usage: usage)));
await tester.pump();
expect(find.text('USAGE'), findsOneWidget);
expect(find.text('15% used · resets Jun 12, 3:39pm'), findsOneWidget);
expect(find.text('53% used · resets Jun 15, 6:59pm'), findsOneWidget);
expect(find.text('0% used'), findsOneWidget);
});
testWidgets('no stats and no usage → the control strip plus the placeholder', (tester) async {
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null)));
await tester.pump();
expect(find.text('SESSION'), findsOneWidget); // controls always present
expect(find.text('No activity recorded yet.'), findsOneWidget);
expect(find.text('USAGE'), findsNothing);
});
testWidgets('renders a WORKFLOWS row per live run with its done/total count (T-416)', (tester) async {
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
run = run.foldEvent({
'subtype': 'task_progress',
'tool_use_id': 'x1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
});
await tester.pumpWidget(harness(f, ActivityTabView(stats: const ClaudeStats(), primaryStatus: null, config: null, workflows: {'x1': run})));
await tester.pump();
expect(find.text('WORKFLOWS'), findsOneWidget);
expect(find.text('parallel-words'), findsOneWidget);
expect(find.text('1/2 agents'), findsOneWidget);
});
testWidgets('no workflows → no WORKFLOWS section', (tester) async {
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null)));
await tester.pump();
expect(find.text('WORKFLOWS'), findsNothing);
});
}
@@ -156,11 +156,16 @@ void main() {
});
testWidgets('arrow-down + Enter completes the selected command (no submit)', (tester) async {
// The composer unions kClideOwnedCommands onto the resolver's list
// (T-162), so '/m' yields [mcp, memory, model] — 'mcp' joined the owned
// set in T-413. Two arrow-downs reach 'model'.
final submitted = await pumpWithCommands(tester, ['model', 'memory']);
await tester.enterText(find.byType(EditableText), '/m'); // → [memory, model]
await tester.enterText(find.byType(EditableText), '/m');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // select 'model'
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // → 'memory'
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // → 'model'
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
@@ -20,7 +20,7 @@ import '../../helpers/widget_harness.dart';
// ---------------------------------------------------------------------------
// Minimal fake process so orchestrator tests don't need a real `claude` binary.
// ---------------------------------------------------------------------------
class _FakeProc implements StreamJsonProcess {
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -133,6 +133,72 @@ void main() {
expect(find.text('Claude environment not loaded.'), findsOneWidget);
});
testWidgets('Activity session controls publish their slash commands (T-415)', (tester) async {
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('SESSION'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('clear session'));
await tester.tap(find.bySemanticsLabel('compact session'));
await tester.tap(find.bySemanticsLabel('refresh usage session'));
await tester.pump();
expect(published.map((m) => m.data['text']), ['/clear', '/compact', '/usage']);
});
testWidgets('a settings control publishes its slash command on pick (T-414)', (tester) async {
final dir = Directory.systemTemp.createTempSync('cfg');
addTearDown(() => dir.deleteSync(recursive: true));
final config = ClaudeConfig(globalDir: dir, cacheDir: dir);
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
await tester.pumpAndSettle();
// The three live controls render alongside the read-only rows.
expect(find.text('model'), findsOneWidget);
expect(find.text('effort'), findsOneWidget);
expect(find.text('permission mode'), findsOneWidget);
// Open the effort control and pick a level → the explicit slash command
// goes out on the bus (the primary pane executes it via _send, D-6).
await tester.tap(find.bySemanticsLabel(RegExp('effort: .*Click to change.')));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('xhigh'));
await tester.pumpAndSettle();
expect(published, hasLength(1));
expect(published.single.data['text'], '/effort xhigh');
});
testWidgets('a meta.tab message switches the sub-tab (T-413 slash navigation)', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
// /config (and /mcp, /agents, /hooks) publish this from the Claude pane.
f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'config'});
await tester.pump();
await tester.pump();
expect(find.text('Claude environment not loaded.'), findsOneWidget); // Config tab (no env in fixture)
f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'activity'});
await tester.pump();
await tester.pump();
expect(find.text('TODAY'), findsOneWidget); // back on Activity
// An unknown tab name is ignored.
f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'bogus'});
await tester.pump();
await tester.pump();
expect(find.text('TODAY'), findsOneWidget);
});
testWidgets('a team spawn auto-fronts the Team tab', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
@@ -1043,4 +1109,35 @@ void main() {
expect(find.text('MCP SERVERS · 0'), findsOneWidget);
});
});
group('T-416 workflow runs in the Activity tab', () {
testWidgets('a primary workflow run surfaces as a WORKFLOWS row', (tester) async {
_FakeProc? proc;
final orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => proc = _FakeProc());
await orch.spawn(const SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.activity)));
await tester.pump();
// The harness emits the workflow progress on the primary session's wire.
proc!._ctl.add(
jsonEncode({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_wf',
'summary': 'orchestrating',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
}),
);
await tester.pump();
await tester.pump();
expect(find.text('WORKFLOWS'), findsOneWidget);
expect(find.text('1/2 agents'), findsOneWidget);
orch.dispose();
});
});
}
+193 -1
View File
@@ -14,18 +14,20 @@ import 'dart:convert';
import 'package:clide/builtin/claude/src/claude_composer.dart';
import 'package:clide/builtin/claude/src/claude_pane.dart';
import 'package:clide/builtin/claude/src/conversation_view.dart';
import 'package:clide/builtin/claude/src/model_picker_card.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/session_picker.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
class _FakeProc implements StreamJsonProcess {
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -52,15 +54,18 @@ void main() {
late ClaudeSessionOrchestrator orch;
late String root;
final created = <_FakeProc>[];
final spawnArgs = <List<String>>[];
setUp(() async {
f = await KernelFixture.create();
created.clear();
spawnArgs.clear();
root = '/repo-a';
orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
spawnArgs.add(sessionArgs);
return p;
},
);
@@ -162,6 +167,44 @@ void main() {
expect(proc.writes.any((w) => w.contains('hello there')), isTrue);
});
testWidgets('a Workflow run renders its dedicated card in the conversation (T-416)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
final semantics = tester.ensureSemantics();
await act(tester, () {
proc.feed({
'type': 'assistant',
'uuid': 'a-wf',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': 'toolu_wf',
'name': 'Workflow',
'input': {'script': 'await parallel([])'},
},
],
},
});
proc.feed({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_wf',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'first agent', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'second agent', 'state': 'start'},
],
});
});
// The dedicated workflow collapser, with its live done/total agent counter.
expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget);
semantics.dispose();
});
testWidgets('/clear empties the deterministic session in place', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final firstProc = created.single;
@@ -175,6 +218,20 @@ void main() {
expect(orch.byId('primary')!.sessionId, id);
});
testWidgets('/clear in a fork pane clears instead of re-forking (T-375)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false, isPrimary: false, secondaryIndex: 1, forkSourceId: 'source-session-uuid'));
// First bind forks from the source.
expect(spawnArgs.single, containsAll(['--fork-session', 'source-session-uuid']));
await act(tester, () => composer(tester).onSubmit('/clear'));
// The respawn must NOT fork the original again — the fork source is a
// one-shot spawn parameter consumed by the first bind.
expect(spawnArgs, hasLength(2));
expect(spawnArgs.last, isNot(contains('--fork-session')));
expect(spawnArgs.last, isNot(contains('source-session-uuid')));
});
testWidgets('/fork delegates to the onFork callback with the session id', (tester) async {
String? forkedWith;
await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid));
@@ -183,6 +240,52 @@ void main() {
expect(forkedWith, primarySessionId('/repo-a'));
});
testWidgets('/model with an argument sends set_model, never a user message (T-408)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(tester, () => composer(tester).onSubmit('/model sonnet'));
final sent = proc.writes.map((w) => jsonDecode(w) as Map).toList();
final setModel = sent.where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList();
expect(setModel, hasLength(1));
expect((setModel.single['request'] as Map)['model'], 'sonnet');
expect(sent.any((m) => m['type'] == 'user'), isFalse, reason: '/model must not be forwarded as message text');
expect(find.byType(ModelPickerCard), findsNothing);
});
testWidgets('bare /model swaps the picker in; picking sends set_model and restores the composer (T-408)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(tester, () => composer(tester).onSubmit('/model'));
expect(find.byType(ModelPickerCard), findsOneWidget);
expect(find.byType(ClaudeComposer), findsNothing, reason: 'the picker takes the interaction zone (D-78)');
tester.widget<ModelPickerCard>(find.byType(ModelPickerCard)).onPick('opus');
await tester.pump();
expect(find.byType(ModelPickerCard), findsNothing);
expect(find.byType(ClaudeComposer), findsOneWidget);
final setModel = proc.writes.map((w) => jsonDecode(w) as Map).where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList();
expect((setModel.single['request'] as Map)['model'], 'opus');
});
testWidgets('Esc cancels the /model picker without sending (T-408)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(tester, () => composer(tester).onSubmit('/model'));
expect(find.byType(ModelPickerCard), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byType(ModelPickerCard), findsNothing);
expect(find.byType(ClaudeComposer), findsOneWidget);
expect(proc.writes.any((w) => w.contains('set_model')), isFalse);
});
testWidgets('cycling permission mode sends a control message', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
@@ -274,4 +377,93 @@ void main() {
expect(created, hasLength(1));
expect(proc.killed, isFalse);
});
// ---- T-410 epic: bus-driven owned commands (T-412/T-413/T-414) ----------
// The sidebar controls publish slash-command text on builtin.claude/command;
// the primary pane executes it through _send — the same path as typing.
group('command bus → owned slash commands', () {
Future<void> command(WidgetTester tester, String text) => act(tester, () => f.services.messages.publish('builtin.claude', 'command', {'text': text}));
testWidgets('/effort <level> respawns the session carrying --effort (T-412)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
expect(created, hasLength(1));
await command(tester, '/effort xhigh');
expect(created, hasLength(2), reason: 'effort change = respawn');
final args = spawnArgs.last;
final i = args.indexOf('--effort');
expect(i, isNonNegative, reason: 'args: $args');
expect(args[i + 1], 'xhigh');
// The pane records the level on the session status (the wire never
// reports effort).
expect(orch.byId('primary')!.session.status.effort, 'xhigh');
});
testWidgets('/effort with an unknown level notices and does NOT respawn (T-412)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/effort warp9');
expect(created, hasLength(1));
expect(find.textContaining('unknown effort "warp9"'), findsOneWidget);
});
testWidgets('bare /effort opens the effort picker in the interaction zone (T-412)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/effort');
expect(find.byType(ModelPickerCard), findsOneWidget);
expect(find.text('effort'), findsOneWidget); // the picker title
});
testWidgets('/permissions <mode> sends set_permission_mode (T-413)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/permissions plan');
final proc = created.single;
expect(proc.writes.any((w) => w.contains('set_permission_mode') && w.contains('"plan"')), isTrue, reason: proc.writes.join('\n'));
});
testWidgets('bare /permissions opens the mode picker (T-413)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/permissions');
expect(find.byType(ModelPickerCard), findsOneWidget);
expect(find.text('permissions'), findsOneWidget);
});
testWidgets('/status and /config navigate the Claude sidebar (T-413)', (tester) async {
final tabs = <String?>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((m) => tabs.add(m.data['tab'] as String?));
addTearDown(sub.cancel);
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/status');
await command(tester, '/config');
await command(tester, '/mcp');
expect(tabs, ['activity', 'config', 'config']);
});
testWidgets('/memory opens CLAUDE.md in the editor (T-413)', (tester) async {
final opened = <String?>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String?);
return IpcResponse.ok(id: '', data: const {});
});
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/memory');
expect(opened, ['/repo-a/CLAUDE.md']);
});
testWidgets('/help renders a local clide summary card (T-413)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
await command(tester, '/help');
expect(find.textContaining('clide commands:'), findsOneWidget);
expect(find.text('clide'), findsOneWidget); // synthetic card attribution
});
testWidgets('a TUI-only command becomes a notice card, never reaching the session (T-411)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final before = created.single.writes.length;
await command(tester, '/doctor');
expect(find.textContaining('/doctor is a Claude Code TUI command'), findsOneWidget);
expect(created.single.writes.length, before, reason: 'nothing forwarded');
});
});
}
@@ -106,4 +106,33 @@ void main() {
expect(seg.trailing, isNull);
});
});
group('parseUsageText (T-415)', () {
// The probed 2.1.175 /usage response shape.
const probed =
'You are currently using your subscription to power your Claude Code usage\n'
'\n'
'Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)\n'
'Current week (all models): 53% used · resets Jun 15, 6:59pm (Europe/Amsterdam)\n'
'Current week (Sonnet only): 0% used';
test('parses the probed response, stripping timezone parentheticals', () {
final u = parseUsageText(probed)!;
expect(u.session, '15% used · resets Jun 12, 3:39pm');
expect(u.week, '53% used · resets Jun 15, 6:59pm');
expect(u.weekSonnet, '0% used');
});
test('tolerates missing lines', () {
final u = parseUsageText('Current session: 9% used')!;
expect(u.session, '9% used');
expect(u.week, isNull);
expect(u.weekSonnet, isNull);
});
test('non-usage text parses to null', () {
expect(parseUsageText("/effort isn't available in this environment."), isNull);
expect(parseUsageText('plain prose'), isNull);
});
});
}
@@ -1,6 +1,10 @@
/// T-297: when the bottom interaction zone resizes, the conversation re-anchors
/// to the tail (if pinned there) so content isn't left hidden behind the taller
/// box — and leaves a scrolled-up reader undisturbed.
///
/// T-368: the same gate applies to NEW ITEMS — they arrive on every streamed
/// token, and following the tail unconditionally yanked a scrolled-up reader
/// to the bottom for the whole reply.
library;
import 'dart:async';
@@ -24,7 +28,7 @@ void main() {
ScrollPosition scrollPos(WidgetTester tester) => tester.state<ScrollableState>(find.byType(Scrollable).first).position;
Future<ConversationController> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
Future<(ConversationController, StreamController<ConversationItem>)> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
tester.view.physicalSize = const Size(600, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -58,7 +62,7 @@ void main() {
stream.add(_asst('conversation line number $i', i));
}
await tester.pumpAndSettle();
return c;
return (c, stream);
}
testWidgets('a growing bottom zone re-anchors the tail when pinned to bottom', (tester) async {
@@ -95,4 +99,42 @@ void main() {
expect(after.pixels, closeTo(before, 1), reason: 'offset preserved; not re-anchored to bottom');
expect(after.pixels, lessThan(after.maxScrollExtent - 8), reason: 'still not at the tail');
});
testWidgets('new streamed items keep following the tail when pinned', (tester) async {
final bottomH = ValueNotifier<double>(40);
addTearDown(bottomH.dispose);
final (_, stream) = await pump(tester, bottomH);
final p = scrollPos(tester);
expect(p.pixels, closeTo(p.maxScrollExtent, 1), reason: 'starts pinned to the tail');
for (var i = 40; i < 60; i++) {
stream.add(_asst('streamed delta number $i', i));
}
await tester.pumpAndSettle();
final p2 = scrollPos(tester);
expect(p2.pixels, closeTo(p2.maxScrollExtent, 1), reason: 'still pinned after new items streamed in');
});
testWidgets('new streamed items do not yank a scrolled-up reader (T-368)', (tester) async {
final bottomH = ValueNotifier<double>(40);
addTearDown(bottomH.dispose);
final (_, stream) = await pump(tester, bottomH);
// Scroll up, away from the tail.
scrollPos(tester).jumpTo(30);
await tester.pump();
final before = scrollPos(tester).pixels;
expect(before, closeTo(30, 1));
for (var i = 40; i < 60; i++) {
stream.add(_asst('streamed delta number $i', i));
}
await tester.pumpAndSettle();
final after = scrollPos(tester);
expect(after.pixels, closeTo(before, 1), reason: 'reading position preserved while the reply streams');
expect(after.pixels, lessThan(after.maxScrollExtent - 8), reason: 'still not at the tail');
});
}
+108 -1
View File
@@ -13,10 +13,12 @@ import 'package:clide/builtin/claude/src/conversation_view.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/kernel.dart' show PaneKeyNav;
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey;
import 'package:flutter/widgets.dart' show Builder, Focus, Image, FileImage, MediaQuery, Scrollable, ScrollableState, ValueKey;
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -154,6 +156,7 @@ void main() {
Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
Map<String, WorkflowRun> workflows = const {},
FoldLevel foldLevel = FoldLevel.none,
}) async {
tester.view.physicalSize = const Size(900, 700);
@@ -178,6 +181,7 @@ void main() {
hiddenToolUseIds: hiddenToolUseIds,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
workflows: workflows,
foldLevel: foldLevel,
),
),
@@ -196,6 +200,100 @@ void main() {
expect(find.text('Waiting for Claude…'), findsOneWidget);
});
testWidgets('vim G / gg / j scroll the conversation under vim.normal (T-406)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
// Enough prose to overflow the 700px viewport so there's room to scroll.
await pumpWith(tester, [for (var i = 0; i < 40; i++) AssistantTextMessage(uuid: 'a$i', timestamp: _t, isSidechain: false, text: 'line number $i')]);
// Focus the pane's nav region (its own Focus is PaneKeyNav's outermost).
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
node.requestFocus();
await tester.pump();
final pos = tester.state<ScrollableState>(find.byType(Scrollable).first).position;
expect(pos.maxScrollExtent, greaterThan(0), reason: 'content must overflow to scroll');
// G → jump to the bottom.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(pos.pixels, pos.maxScrollExtent);
// gg → jump to the top.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.pump();
expect(pos.pixels, 0);
// j → down one line (48px); k → back up.
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.pump();
expect(pos.pixels, 48);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
await tester.pump();
expect(pos.pixels, 0);
// ctrl+d / ctrl+u → half a viewport down then back up.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.pump();
expect(pos.pixels, greaterThan(0));
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(pos.pixels, 0);
// h / l / o have no reader-pane semantics — they don't move the scroll.
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
expect(pos.pixels, 0);
});
testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async {
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
run = run.foldEvent({
'subtype': 'task_progress',
'tool_use_id': 'x1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'do alpha', 'model': 'haiku', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'do beta', 'model': 'haiku', 'state': 'start'},
],
});
await pumpWith(
tester,
[
_tool('Workflow', const {'script': 'await parallel([])'}),
],
workflows: {'x1': run},
);
// Collapsed: the dedicated workflow collapser with its done/total counter
// and the workflow name as the summary (no description set → no duplicate).
expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget);
expect(find.text('parallel-words'), findsOneWidget);
expect(find.text('do alpha'), findsNothing); // folded while collapsed
// Expand → the per-agent rows show.
await tester.tap(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'));
await tester.pumpAndSettle();
expect(find.text('do alpha'), findsOneWidget);
expect(find.text('do beta'), findsOneWidget);
});
testWidgets('a Workflow tool-use with no run yet falls back to the generic tool card (T-416)', (tester) async {
await pumpWith(tester, [
_tool('Workflow', const {'script': 'await parallel([])'}),
]);
// No run snapshot → the generic tool collapser labeled by the tool name.
expect(find.bySemanticsLabel('Workflow, 1 step, collapsed'), findsOneWidget);
});
testWidgets('unfolded conversation cards carry stable per-item identity keys (T-285)', (tester) async {
tester.view.physicalSize = const Size(900, 800);
tester.view.devicePixelRatio = 1.0;
@@ -833,6 +931,15 @@ void main() {
expect(find.text('no independent source to follow'), findsOneWidget);
});
testWidgets('synthetic CLI-local output renders as a muted "clide" card, not claude prose (T-411)', (tester) async {
await pumpWith(tester, [
AssistantTextMessage(uuid: 's1', timestamp: _t, isSidechain: false, text: "/effort isn't available in this environment.", synthetic: true),
]);
expect(find.text('clide'), findsOneWidget);
expect(find.text('claude'), findsNothing);
expect(find.textContaining("isn't available"), findsOneWidget);
});
testWidgets('an ordinary Bash card has no live-tail segment (T-325)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(uuid: 'b2', timestamp: _t, isSidechain: false, toolUseId: 'tb2', name: 'Bash', input: const {'command': 'ls -la'}),
@@ -0,0 +1,148 @@
/// T-391: the claude builtin's command handlers must honor the D-6
/// exit-code contract — a failure is an ERROR envelope (non-zero CLI
/// exit), never `ok` with an `error` field a script can't detect.
/// `clide claude.agent.set-permission-mode bogus` exited 0 before this.
/// Plus the activation lifecycle + command success paths.
library;
import 'package:clide/builtin/claude/src/activity_cluster.dart' show kActivityFoldLevelKey;
import 'package:clide/builtin/claude/src/claude_config.dart' show activeClaudeConfig;
import 'package:clide/builtin/claude/src/extension.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart' show activeSessionOrchestrator;
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized(); // GlobalKey lookups in handlers
final ext = ClaudeExtension();
CommandContribution cmd(String id) => ext.contributions.whereType<CommandContribution>().firstWhere((c) => c.id == id);
group('failure paths return error envelopes (T-391, D-6)', () {
// (command id, args, expected error kind)
final cases = <(String, List<String>, String)>[
('claude.agent.show', [], IpcErrorKind.userError),
('claude.agent.hide', [], IpcErrorKind.userError),
('claude.agent.close', [], IpcErrorKind.userError),
('claude.agent.mute', [], IpcErrorKind.userError),
('claude.agent.unmute', [], IpcErrorKind.userError),
('claude.agent.inject-message', [], IpcErrorKind.userError),
('claude.agent.inject-message', ['some-id'], IpcErrorKind.userError),
('claude.agent.set-permission-mode', [], IpcErrorKind.userError),
('claude.agent.set-permission-mode', ['some-id'], IpcErrorKind.userError),
('claude.agent.set-permission-mode', ['some-id', 'bogus'], IpcErrorKind.userError),
('claude.mode.cycle', [], IpcErrorKind.notFound),
('claude.task.reassign', [], IpcErrorKind.userError),
('claude.team-chat.post', [], IpcErrorKind.userError),
('claude.agent.fork', [], IpcErrorKind.userError),
// No orchestrator is wired in this test (extension not activated),
// so a fork with a source id fails as unavailable tooling.
('claude.agent.fork', ['some-id'], IpcErrorKind.toolError),
];
for (final (id, args, kind) in cases) {
test('$id ${args.isEmpty ? '(no args)' : args.join(' ')}$kind', () async {
final r = await cmd(id).run(args);
expect(r.ok, isFalse, reason: 'a failure must not report ok');
expect(r.error!.kind, kind);
expect(r.error!.code, isNot(0), reason: 'the CLI must exit non-zero');
});
}
});
group('activated lifecycle + success paths', () {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
f.services.extensions.register(ClaudeExtension());
await f.services.extensions.activate('builtin.claude');
expect(f.services.extensions.isActivated('builtin.claude'), isTrue, reason: f.services.extensions.failedExtensions.toString());
});
tearDown(() async {
await f.services.extensions.deactivate('builtin.claude');
await f.dispose();
});
Future<IpcResponse> run(String command, [List<String> args = const []]) {
final c = f.services.commands.get(command);
expect(c, isNotNull, reason: '$command should be registered after activation');
return c!.run(args);
}
test('roster verbs succeed once the orchestrator is wired (no-op on unknown ids)', () async {
for (final verb in ['claude.agent.show', 'claude.agent.hide', 'claude.agent.close', 'claude.agent.mute', 'claude.agent.unmute']) {
final r = await run(verb, ['no-such-session']);
expect(r.ok, isTrue, reason: '$verb is idempotent on unknown ids');
}
final inject = await run('claude.agent.inject-message', ['no-such-session', 'hello']);
expect(inject.ok, isTrue);
final mode = await run('claude.agent.set-permission-mode', ['no-such-session', 'plan']);
expect(mode.ok, isTrue);
expect(mode.data['mode'], 'plan');
});
test('claude.new-secondary and kill-all-sessions succeed with no live panes', () async {
expect((await run('claude.new-secondary')).ok, isTrue);
final killed = await run('claude.kill-all-sessions');
expect(killed.ok, isTrue);
expect(killed.data['status'], 'killed');
});
test('claude.activity.fold-level cycles and persists the setting (T-235)', () async {
final r1 = await run('claude.activity.fold-level');
expect(r1.ok, isTrue);
final first = r1.data['foldLevel'] as String;
expect(f.services.settings.get<String>(kActivityFoldLevelKey), first);
final r2 = await run('claude.activity.fold-level');
expect(r2.data['foldLevel'], isNot(first), reason: 'the level advances each call');
});
test('claude.team-chat.open and .post succeed', () async {
expect((await run('claude.team-chat.open')).ok, isTrue);
final broadcast = await run('claude.team-chat.post', ['hello', 'team']);
expect(broadcast.ok, isTrue);
final directed = await run('claude.team-chat.post', ['@tyre', 'hello', 'you']);
expect(directed.ok, isTrue);
expect(directed.data['to'], 'tyre');
});
test('claude.session-storage degrades cleanly when files.root is unavailable', () async {
// The fixture IPC has no files.root stub → the handler bails out ok
// without opening the dialog.
final r = await run('claude.session-storage');
expect(r.ok, isTrue);
});
test('an image-show message with no live session is dropped silently (T-249)', () async {
f.services.messages.publish('test', imageShowChannel, {'path': '/tmp/x.png'});
f.services.messages.publish('test', imageShowChannel, {'path': ''});
await Future<void>.delayed(Duration.zero);
// Nothing to assert beyond "no throw" — there is no conversation to
// receive the card and the CLI already acked at publish time.
});
test('a project switch closes sessions that belong to the old root (T-269)', () async {
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
await Future<void>.delayed(Duration.zero);
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
await Future<void>.delayed(Duration.zero);
f.services.events.emit(const ProjectOpened(path: '/repo-two'));
await Future<void>.delayed(Duration.zero);
// No live sessions in this fixture — the sweep runs over an empty set.
expect(activeSessionOrchestrator!.sessions, isEmpty);
});
test('deactivate clears the builtin-owned singletons', () async {
expect(activeSessionOrchestrator, isNotNull);
await f.services.extensions.deactivate('builtin.claude');
expect(activeSessionOrchestrator, isNull);
expect(activeClaudeConfig, isNull);
});
});
}
@@ -0,0 +1,108 @@
/// Widget tests for [ModelPickerCard] — the bare `/model` interaction-zone
/// picker (T-408): rendering, current-model marking, number-key / arrow+Enter
/// selection, and Esc cancel.
library;
import 'package:clide/builtin/claude/src/model_picker_card.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
const models = [
ModelOption(value: 'default', displayName: 'Default', description: 'recommended'),
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast'),
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
];
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
group('modelOptionIsCurrent', () {
test('matches by exact value or alias containment, never for default', () {
const sonnet = ModelOption(value: 'sonnet', displayName: 'Sonnet');
expect(modelOptionIsCurrent(sonnet, 'sonnet'), isTrue);
expect(modelOptionIsCurrent(sonnet, 'claude-sonnet-4-6'), isTrue);
expect(modelOptionIsCurrent(sonnet, 'claude-opus-4-8'), isFalse);
expect(modelOptionIsCurrent(sonnet, null), isFalse);
expect(modelOptionIsCurrent(const ModelOption(value: 'default', displayName: 'Default'), 'claude-opus-4-8'), isFalse);
});
});
testWidgets('renders every model with the current one marked', (tester) async {
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: 'claude-sonnet-4-6', onPick: (_) {}, onCancel: () {})));
expect(find.textContaining('Default'), findsOneWidget);
expect(find.textContaining('● Sonnet'), findsOneWidget); // current
expect(find.textContaining('○ Opus'), findsOneWidget);
expect(find.textContaining('most capable'), findsOneWidget);
});
testWidgets('tapping an entry picks its value', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () {})));
await tester.tap(find.textContaining('Opus'));
await tester.pump();
expect(picked, 'opus');
});
testWidgets('a number key picks directly', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () {})));
await tester.sendKeyEvent(LogicalKeyboardKey.digit2);
await tester.pump();
expect(picked, 'sonnet');
});
testWidgets('arrows move the highlight and Enter picks it', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: 'claude-sonnet-4-6', onPick: (v) => picked = v, onCancel: () {})));
// Highlight starts on the current model (sonnet, index 1).
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // → opus
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(picked, 'opus');
});
testWidgets('Esc cancels without picking', (tester) async {
String? picked;
var cancelled = false;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () => cancelled = true)));
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(cancelled, isTrue);
expect(picked, isNull);
});
testWidgets('an out-of-range number key is ignored', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () {})));
await tester.sendKeyEvent(LogicalKeyboardKey.digit9);
await tester.pump();
expect(picked, isNull);
});
testWidgets('effort reuse: custom title + exact-match marking (T-412)', (tester) async {
// Exact-match isCurrent: `high` must not be marked when effort is `xhigh`.
await tester.pumpWidget(
harness(
f,
ModelPickerCard(
title: 'effort',
models: kEffortLevels,
currentModel: 'xhigh',
isCurrent: (o, c) => c != null && o.value == c,
onPick: (_) {},
onCancel: () {},
),
),
);
expect(find.text('effort'), findsOneWidget); // the custom header
expect(find.textContaining('● xhigh'), findsOneWidget);
expect(find.textContaining('○ high'), findsOneWidget); // NOT containment-marked
});
}
@@ -18,7 +18,7 @@ import 'package:test/test.dart';
// Minimal fake process — same as session_orchestrator_test.dart.
// ---------------------------------------------------------------------------
class _FakeProc implements StreamJsonProcess {
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -7,7 +7,7 @@ import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeProc implements StreamJsonProcess {
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -22,14 +22,17 @@ class _FakeProc implements StreamJsonProcess {
void main() {
late List<_FakeProc> created;
late List<List<String>> spawnedArgs;
late ClaudeSessionOrchestrator orch;
setUp(() {
created = [];
spawnedArgs = [];
orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
spawnedArgs.add(sessionArgs);
return p;
},
);
@@ -37,6 +40,19 @@ void main() {
SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible);
test('a spec with effort spawns claude with --effort <level> (T-412)', () async {
await orch.spawn(SpawnSpec(id: 'e1', role: 'primary', sessionId: 'e1-uuid', cwd: '/repo', effort: 'xhigh'));
final args = spawnedArgs.single;
final i = args.indexOf('--effort');
expect(i, isNonNegative, reason: 'sessionArgs: $args');
expect(args[i + 1], 'xhigh');
});
test('a spec without effort spawns without the flag (CLI default applies)', () async {
await orch.spawn(spec('primary'));
expect(spawnedArgs.single, isNot(contains('--effort')));
});
test('spawns multiple concurrent sessions, each with its own process', () async {
await orch.spawn(spec('primary'));
await orch.spawn(spec('teammate:tyre'));
@@ -60,6 +76,32 @@ void main() {
expect(created, hasLength(1));
});
// T-374: spawn() check-then-acts across awaits; without the in-flight
// map, two CONCURRENT spawns both passed the registry check and the
// loser's live claude process was orphaned.
test('two concurrent spawns for one id share one session and one process (T-374)', () async {
final (a, b) = await (orch.spawn(spec('primary')), orch.spawn(spec('primary'))).wait;
expect(identical(a, b), isTrue);
expect(created, hasLength(1));
});
test('a failed spawn clears the in-flight entry so a retry can proceed (T-374)', () async {
var calls = 0;
final flaky = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
calls++;
if (calls == 1) throw StateError('spawn blew up');
final p = _FakeProc();
created.add(p);
return p;
},
);
await expectLater(flaky.spawn(spec('primary')), throwsStateError);
final m = await flaky.spawn(spec('primary'));
expect(m.id, 'primary');
expect(calls, 2);
});
test('hide keeps the process alive and in the registry; show restores it', () async {
await orch.spawn(spec('primary'));
orch.hide('primary');
+89 -1
View File
@@ -43,14 +43,33 @@ void main() {
expect(clideOwnedCommand('/resume'), 'resume');
});
test('recognises /model with and without an argument (T-408)', () {
expect(clideOwnedCommand('/model'), 'model');
expect(clideOwnedCommand('/model sonnet'), 'model');
});
test('returns null for commands clide forwards to Claude', () {
expect(clideOwnedCommand('/model sonnet'), isNull);
expect(clideOwnedCommand('/compact'), isNull);
expect(clideOwnedCommand('not a command'), isNull);
expect(clideOwnedCommand('/clearairspace'), isNull); // token must be exactly "clear"
});
});
group('slashCommandArg', () {
test('returns the trimmed argument after the command token', () {
expect(slashCommandArg('/model sonnet'), 'sonnet');
expect(slashCommandArg('/model claude-opus-4-8 '), 'claude-opus-4-8');
expect(slashCommandArg('/model\tsonnet'), 'sonnet');
});
test('empty for a bare command, null for non-command input', () {
expect(slashCommandArg('/model'), '');
expect(slashCommandArg('/model '), '');
expect(slashCommandArg('hello'), isNull);
expect(slashCommandArg('/foo\nbar'), isNull);
});
});
group('activeSlashQuery', () {
test('matches a slash token at the cursor, including inline', () {
expect(activeSlashQuery('/mod', 4), const SlashQuery(start: 0, query: 'mod'));
@@ -117,4 +136,73 @@ void main() {
expect(r.cursor, 10);
});
});
group('routeSlashCommand (T-411)', () {
// The probed 2.1.175 shape: skills + headless builtins.
const advertised = ['compact', 'context', 'usage', 'whats-next', 'git-commit'];
test('non-command text routes null (normal message send)', () {
expect(routeSlashCommand('hello world', advertised: advertised), isNull);
expect(routeSlashCommand('multi\n/line', advertised: advertised), isNull);
});
test('a path-like leading slash is an unknown token → forward (stays literal)', () {
expect(routeSlashCommand('/tmp/x.log explain', advertised: advertised), SlashRoute.forward);
});
test('owned beats everything', () {
for (final t in [
'/clear',
'/resume',
'/fork',
'/model opus',
'/effort high',
'/permissions plan',
'/status',
'/config',
'/mcp',
'/agents',
'/hooks',
'/memory',
'/help',
]) {
expect(routeSlashCommand(t, advertised: advertised), SlashRoute.owned, reason: t);
}
});
test('advertised commands (skills + headless builtins) forward', () {
expect(routeSlashCommand('/compact', advertised: advertised), SlashRoute.forward);
expect(routeSlashCommand('/usage', advertised: advertised), SlashRoute.forward);
expect(routeSlashCommand('/whats-next', advertised: advertised), SlashRoute.forward);
});
test('a known TUI-only builtin routes unavailable', () {
for (final t in ['/cost', '/doctor', '/login', '/rewind', '/output-style']) {
expect(routeSlashCommand(t, advertised: advertised), SlashRoute.unavailable, reason: t);
}
});
test('an advertised name shadows the TUI-only catalog (a skill named like a builtin forwards)', () {
// 'cost' is in the catalog but not owned — advertising it wins.
expect(routeSlashCommand('/cost', advertised: ['cost']), SlashRoute.forward);
});
test('an unknown token forwards (stays literal text downstream)', () {
expect(routeSlashCommand('/no-such-thing', advertised: advertised), SlashRoute.forward);
});
});
group('tuiOnlyNotice (T-411)', () {
test('carries the clide-native pointer when the catalog has one', () {
final n = tuiOnlyNotice('cost');
expect(n, contains('/cost is a Claude Code TUI command'));
expect(n, contains('Activity tab'));
});
test('plain notice when there is no pointer', () {
final n = tuiOnlyNotice('terminal-setup');
expect(n, contains('/terminal-setup is a Claude Code TUI command'));
expect(n, isNot(contains('')));
});
});
}
@@ -3,19 +3,28 @@ import 'dart:convert';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:test/test.dart';
class _FakeProc implements StreamJsonProcess {
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>();
final List<String> writes = [];
bool killed = false;
/// Drives the T-361 exit watch; never completes unless a test exits it.
final exit = Completer<int>();
final List<String> stderr = [];
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(line);
@override
Future<void> kill() async => killed = true;
@override
Future<int> get exitCode => exit.future;
@override
List<String> get stderrTail => stderr;
void emit(String line) => _ctl.add(line);
}
@@ -157,10 +166,112 @@ void main() {
session.items.listen(items.add);
session.statusStream.listen(statuses.add);
session.start();
// start() always sends the `initialize` handshake (T-408); drop it so the
// write assertions below stay about what each test sends. The handshake
// itself is asserted in the 'initialize handshake' group.
proc.writes.clear();
});
tearDown(() => session.dispose());
group('initialize handshake + model list (T-408)', () {
test('start() sends the initialize handshake even with no MCP servers', () {
final p = _FakeProc();
final s = StreamJsonSession(p);
addTearDown(s.dispose);
s.start();
final init = jsonDecode(p.writes.single) as Map;
expect(init['type'], 'control_request');
expect((init['request'] as Map)['subtype'], 'initialize');
expect((init['request'] as Map)['sdkMcpServers'], isEmpty);
});
test('the initialize response populates availableModels', () async {
final p = _FakeProc();
final s = StreamJsonSession(p);
addTearDown(s.dispose);
s.start();
final rid = (jsonDecode(p.writes.single) as Map)['request_id'];
expect(s.availableModels, isEmpty);
p.emit(
jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {
'commands': <dynamic>[],
'models': [
{'value': 'default', 'displayName': 'Default', 'description': 'recommended'},
{'value': 'sonnet', 'displayName': 'Sonnet'},
{'value': 12345}, // malformed entry → skipped
],
},
},
}),
);
await Future<void>.delayed(Duration.zero);
expect(s.availableModels, hasLength(2));
expect(s.availableModels[0].value, 'default');
expect(s.availableModels[0].description, 'recommended');
expect(s.availableModels[1].displayName, 'Sonnet');
expect(s.availableModels[1].description, isEmpty);
});
});
group('setModel (T-408)', () {
test('sends a set_model control_request and optimistically merges status', () async {
session.setModel('sonnet');
final sent = jsonDecode(proc.writes.single) as Map;
expect(sent['type'], 'control_request');
expect((sent['request'] as Map)['subtype'], 'set_model');
expect((sent['request'] as Map)['model'], 'sonnet');
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'sonnet');
});
test('setModel(default) does not guess the resolved model', () async {
session.setModel('default');
await Future<void>.delayed(Duration.zero);
expect(statuses, isEmpty, reason: 'only the CLI knows what default resolves to');
});
test('an error response rolls the model back and surfaces the message', () async {
final errors = <String>[];
session.modelErrors.listen(errors.add);
proc.emit(initEvent()); // model: claude-opus-4-7
await Future<void>.delayed(Duration.zero);
session.setModel('bogus-model');
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'bogus-model'); // optimistic
final rid = (jsonDecode(proc.writes.single) as Map)['request_id'];
proc.emit(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unknown model: bogus-model'},
}),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'claude-opus-4-7', reason: 'rolled back');
expect(errors, ['Unknown model: bogus-model']);
});
test('a success response keeps the optimistic model', () async {
session.setModel('opus');
final rid = (jsonDecode(proc.writes.single) as Map)['request_id'];
proc.emit(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': rid},
}),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'opus');
});
});
test('parses assistant text + tool_use events into items', () async {
proc.emit(assistantText('hello there'));
proc.emit(assistantToolUse());
@@ -193,6 +304,21 @@ void main() {
expect(statuses, hasLength(1));
});
// T-274 root cause: the init event fired before the pane subscribed and
// the plain broadcast stream dropped it — the status bar stayed blank.
test('subscribing AFTER the init event still yields the status (T-274/T-386)', () async {
proc.emit(initEvent());
await Future<void>.delayed(Duration.zero);
final late = <SessionStatus>[];
session.statusStream.listen(late.add);
await Future<void>.delayed(Duration.zero);
expect(late, hasLength(1), reason: 'replay-latest delivers the current status to late binders');
expect(late.single.model, 'claude-opus-4-7');
expect(late.single.permissionMode, 'default');
});
test('captures the claude session id from the first event carrying it (T-185)', () async {
final ids = <String>[];
session.sessionIdResolved.listen(ids.add);
@@ -515,6 +641,25 @@ void main() {
expect(statuses.last.permissionMode, 'plan', reason: 'only ExitPlanMode exits plan mode');
});
test('noteEffort merges the effort level into the status (T-412)', () async {
proc.emit(initEvent());
await Future<void>.delayed(Duration.zero);
session.noteEffort('xhigh');
await Future<void>.delayed(Duration.zero);
expect(statuses.last.effort, 'xhigh');
expect(statuses.last.model, 'claude-opus-4-7'); // merge, not replace
});
test('addLocalNotice emits a synthetic clide item and sends nothing (T-411)', () async {
final before = proc.writes.length;
session.addLocalNotice('/status is a Claude Code TUI command');
await Future<void>.delayed(Duration.zero);
final notice = items.whereType<AssistantTextMessage>().single;
expect(notice.synthetic, isTrue);
expect(notice.text, contains('/status'));
expect(proc.writes.length, before); // nothing went to the CLI
});
test('resolvePrompt(deny) writes a deny decision with a message', () async {
proc.emit(canUseTool('req-3'));
await Future<void>.delayed(Duration.zero);
@@ -639,7 +784,9 @@ void main() {
proc.emit(jsonEncode({'type': 'result', 'subtype': 'success'}));
await Future<void>.delayed(Duration.zero);
expect(session.busy, isFalse);
expect(busy, [true, false]);
// Leading false is the replayed seed — busyStream tells a new
// subscriber the CURRENT state before the live updates (T-386).
expect(busy, [false, true, false]);
});
test('dispose kills the process', () async {
@@ -745,4 +892,111 @@ void main() {
expect((r['error'] as Map)['message'], contains('resources/list'));
});
});
// T-361: nothing watched the process itself — a crashed claude just
// looked thoughtful forever.
group('process exit (T-361)', () {
test('exit emits SessionEnd with code + stderr tail and clears busy', () async {
final ends = <SessionEnd>[];
session.endedStream.listen(ends.add);
session.send('do something');
await Future<void>.delayed(Duration.zero);
expect(session.busy, isTrue, reason: 'a send marks the turn in flight');
proc.stderr.addAll(['boom: stack', 'fatal: died']);
proc.exit.complete(70);
await Future<void>.delayed(Duration.zero);
expect(session.busy, isFalse, reason: 'a dead process is not thinking');
expect(ends, hasLength(1));
expect(ends.single.exitCode, 70);
expect(ends.single.stderrTail, ['boom: stack', 'fatal: died']);
expect(session.end, same(ends.single), reason: 'late binders replay via the getter');
});
test('exit clears a pending prompt — it can never be answered', () async {
final pendings = <ToolPrompt?>[];
session.pendingPromptStream.listen(pendings.add);
proc.emit(canUseTool('p1'));
await Future<void>.delayed(Duration.zero);
expect(session.pendingPrompt, isNotNull);
proc.exit.complete(1);
await Future<void>.delayed(Duration.zero);
expect(session.pendingPrompt, isNull);
expect(pendings.last, isNull, reason: 'the composer swaps back from the prompt UI');
});
test('a deliberate dispose suppresses the exit watch', () async {
final p = _FakeProc();
final s = StreamJsonSession(p)..start();
await s.dispose();
p.exit.complete(9); // the kill's exit must not surface as a crash
await Future<void>.delayed(Duration.zero);
expect(s.end, isNull);
});
});
group('BoundedLineBuffer', () {
test('keeps only the last cap lines', () {
final b = BoundedLineBuffer(cap: 3);
for (var i = 0; i < 5; i++) {
b.add('line $i');
}
expect(b.lines, ['line 2', 'line 3', 'line 4']);
});
});
group('workflow runs (T-416)', () {
test('accumulates a run from system task_* events keyed by tool_use_id', () async {
final p = _FakeProc();
final session = StreamJsonSession(p)..start();
final snapshots = <Map<String, WorkflowRun>>[];
session.workflowsStream.listen(snapshots.add);
p.emit(
jsonEncode({
'type': 'system',
'subtype': 'task_started',
'task_id': 'wy01fihjt',
'tool_use_id': 'toolu_wf',
'description': 'Two agents',
'workflow_name': 'parallel-words',
}),
);
p.emit(
jsonEncode({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_wf',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'alpha', 'state': 'start'},
{'type': 'workflow_agent', 'index': 2, 'label': 'beta', 'state': 'done'},
],
}),
);
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_notification', 'tool_use_id': 'toolu_wf', 'status': 'completed', 'summary': 'done'}));
await Future<void>.delayed(Duration.zero);
final run = session.workflows['toolu_wf'];
expect(run, isNotNull);
expect(run!.name, 'parallel-words');
expect(run.agentCount, 2);
expect(run.doneCount, 1);
expect(run.done, isTrue);
expect(run.summary, 'done');
expect(snapshots, isNotEmpty);
});
test('a workflow system event produces no conversation item', () async {
final p = _FakeProc();
final session = StreamJsonSession(p)..start();
final items = <ConversationItem>[];
session.items.listen(items.add);
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_progress', 'tool_use_id': 'toolu_wf', 'workflow_progress': const []}));
await Future<void>.delayed(Duration.zero);
expect(items, isEmpty);
expect(session.workflows.containsKey('toolu_wf'), isTrue);
});
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ import 'package:flutter_test/flutter_test.dart';
import '../../helpers/fake_ipc.dart';
class _FakeProc implements StreamJsonProcess {
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
@override
@@ -1,78 +1,17 @@
/// Tests for TranscriptPublisher — bridges a TranscriptReader onto the
/// kernel MessageBus (T-137/D-75). Pure Dart: MessageBus + reader have no
/// Flutter dependency, so this runs under `package:test`.
/// Tests for the ClaudeConversation bus-addressing constants. The
/// TranscriptPublisher class this file used to cover was removed in the
/// T-385 dead-code sweep (no production constructor calls since the
/// stream-json pivot, D-77).
library;
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
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},
};
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},
],
},
};
void main() {
group('TranscriptPublisher', () {
late Directory base;
const workspace = '/pub/ws';
setUp(() async => base = await Directory.systemTemp.createTemp('transcript_publisher_test_'));
tearDown(() async => base.delete(recursive: true));
// Serialized: this MessageBus republish assertion is timing-sensitive and
// flaked in the parallel flutter pool; runs in the --concurrency=1 pass (T-193).
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');
final bus = MessageBus();
addTearDown(bus.dispose);
final received = <Message>[];
// 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 pub = TranscriptPublisher(messages: bus, reader: reader);
await Future<void>.delayed(const Duration(milliseconds: 200));
await sub.cancel();
await pub.dispose();
expect(received, hasLength(2));
expect(received.every((m) => m.data[ClaudeConversation.itemKey] is ConversationItem), isTrue);
final items = received.map((m) => m.data[ClaudeConversation.itemKey]).toList();
expect(items.first, isA<UserMessage>());
expect((items.first as UserMessage).text, 'hello');
expect(items[1], isA<AssistantTextMessage>());
});
test('teammateChannel namespaces by agentId', () {
group('ClaudeConversation addressing', () {
test('sessionChannel + teammateChannel namespace by id', () {
expect(ClaudeConversation.sessionChannel('abc-123'), 'conversation/abc-123');
expect(ClaudeConversation.teammateChannel('coder@team-x'), 'conversation/coder@team-x');
});
@@ -522,6 +522,61 @@ void main() {
expect(const SessionStatus(contextWindow: 0).isEmpty, isFalse);
expect(const SessionStatus(rateLimitInfo: 'rate limited').isEmpty, isFalse);
});
test('effort merges, compares, and flips isEmpty (T-412)', () {
const a = SessionStatus(model: 'm1');
final m = a.merge(const SessionStatus(effort: 'high'));
expect(m.effort, 'high');
expect(m.model, 'm1');
expect(const SessionStatus(effort: 'high'), const SessionStatus(effort: 'high'));
expect(const SessionStatus(effort: 'high'), isNot(const SessionStatus(effort: 'max')));
expect(const SessionStatus(effort: 'low').isEmpty, isFalse);
});
});
group('synthetic CLI-local output (T-411)', () {
String syntheticEvent(String text) => jsonEncode({
'type': 'assistant',
'uuid': 'syn1',
'timestamp': '2026-06-12T10:00:00.000Z',
'message': {
'role': 'assistant',
'model': '<synthetic>',
'content': [
{'type': 'text', 'text': text},
],
},
});
test('a "<synthetic>" assistant message parses with synthetic: true', () {
final parsed = parseTranscriptChunk(syntheticEvent("/effort isn't available in this environment."));
final msg = parsed.items.whereType<AssistantTextMessage>().single;
expect(msg.synthetic, isTrue);
expect(msg.text, contains("isn't available"));
});
test('"<synthetic>" never clobbers the tracked model', () {
final parsed = parseTranscriptChunk(syntheticEvent('usage text'));
expect(parsed.status.model, isNull);
});
test('a real assistant message stays non-synthetic', () {
final chunk = jsonEncode({
'type': 'assistant',
'uuid': 'a2',
'timestamp': '2026-06-12T10:00:00.000Z',
'message': {
'role': 'assistant',
'model': 'claude-fable-5',
'content': [
{'type': 'text', 'text': 'hello'},
],
},
});
final parsed = parseTranscriptChunk(chunk);
expect(parsed.items.whereType<AssistantTextMessage>().single.synthetic, isFalse);
expect(parsed.status.model, 'claude-fable-5');
});
});
group('TranscriptReader — append streaming (filesystem)', () {
+137
View File
@@ -0,0 +1,137 @@
/// Tests for the WorkflowRun model (T-416): folding stream-json `system`
/// task_* events — the exact shapes captured by the spike — into a snapshot.
library;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isWorkflowSystemEvent', () {
test('accepts task_* system events that name a tool_use_id', () {
for (final s in kWorkflowSystemSubtypes) {
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': s, 'tool_use_id': 'toolu_1'}), isTrue, reason: s);
}
});
test('rejects unrelated system subtypes and non-system events', () {
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'init', 'tool_use_id': 'x'}), isFalse);
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'thinking_tokens'}), isFalse);
expect(isWorkflowSystemEvent({'type': 'assistant', 'subtype': 'task_progress', 'tool_use_id': 'x'}), isFalse);
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'task_progress'}), isFalse); // no tool_use_id
});
});
group('foldEvent — phase-less run', () {
test('task_started seeds name/description/taskId', () {
const run = WorkflowRun(toolUseId: 'toolu_1');
final r = run.foldEvent({
'type': 'system',
'subtype': 'task_started',
'task_id': 'wy01fihjt',
'tool_use_id': 'toolu_1',
'description': 'Two agents return one word each',
'workflow_name': 'parallel-words',
'prompt': 'export const meta = ...',
});
expect(r.taskId, 'wy01fihjt');
expect(r.name, 'parallel-words');
expect(r.description, 'Two agents return one word each');
expect(r.running, isTrue);
expect(r.agentCount, 0);
});
test('task_progress merges workflow_agent deltas by index', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
// First delta: two agents start, then get their agentIds + real model.
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'summary': 'Two agents return one word each',
'usage': {'total_tokens': 0, 'tool_uses': 0, 'duration_ms': 28},
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'alpha', 'model': 'haiku', 'state': 'start'},
{'type': 'workflow_agent', 'index': 2, 'label': 'beta', 'model': 'haiku', 'state': 'start'},
{'type': 'workflow_agent', 'index': 1, 'agentId': 'ae51341336dd3a4a0', 'model': 'claude-haiku-4-5-20251001', 'state': 'start'},
],
});
expect(run.agentCount, 2);
expect(run.agents[1]!.label, 'alpha'); // kept from earlier delta
expect(run.agents[1]!.agentId, 'ae51341336dd3a4a0'); // filled by later delta
expect(run.agents[1]!.model, 'claude-haiku-4-5-20251001'); // upgraded
expect(run.summary, 'Two agents return one word each');
// Second delta: agent 2 advances to done.
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 2, 'agentId': 'a210f9290a5f5d089', 'state': 'done'},
],
});
expect(run.agents[2]!.state, WorkflowAgentState.done);
expect(run.agents[1]!.state, WorkflowAgentState.start); // untouched
expect(run.doneCount, 1);
});
test('task_updated and task_notification mark the run done', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
run = run.foldEvent({
'type': 'system',
'subtype': 'task_updated',
'tool_use_id': 'toolu_1',
'patch': {'status': 'completed', 'end_time': 1},
});
expect(run.done, isTrue);
var run2 = const WorkflowRun(toolUseId: 'toolu_1');
run2 = run2.foldEvent({
'type': 'system',
'subtype': 'task_notification',
'tool_use_id': 'toolu_1',
'status': 'completed',
'summary': 'Dynamic workflow completed',
'usage': {'total_tokens': 19306, 'tool_uses': 0, 'duration_ms': 1009},
});
expect(run2.done, isTrue);
expect(run2.summary, 'Dynamic workflow completed');
expect(run2.totalTokens, 19306);
expect(run2.durationMs, 1009);
});
});
group('foldEvent — phased run', () {
test('workflow_phase entries register phases; agents carry phase tags', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'workflow_progress': [
{'type': 'workflow_phase', 'index': 1, 'title': 'Scan'},
{'type': 'workflow_phase', 'index': 2, 'title': 'Fix'},
{'type': 'workflow_agent', 'index': 1, 'label': 'scan it', 'phaseIndex': 1, 'phaseTitle': 'Scan', 'model': 'haiku', 'state': 'start'},
],
});
expect(run.orderedPhases.map((p) => p.title), ['Scan', 'Fix']);
expect(run.agents[1]!.phaseIndex, 1);
expect(run.agents[1]!.phaseTitle, 'Scan');
});
});
test('orderedAgents sorts by fan-out index', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 3, 'label': 'c', 'state': 'start'},
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'start'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
});
expect(run.orderedAgents.map((a) => a.label), ['a', 'b', 'c']);
});
}
@@ -1,6 +1,7 @@
import 'package:clide/builtin/default_layout/default_layout.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -151,5 +152,45 @@ void main() {
// Sidebar auto-expanded.
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
});
test('workspace.tab.next/previous cycle the workspace tabs with wraparound (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final panels = f.services.panels;
for (final id in ['wt.a', 'wt.b', 'wt.c']) {
panels.contribute(TabContribution(id: id, slot: Slots.workspace, title: id, build: (_) => const SizedBox.shrink()));
}
panels.setTabOrder(Slots.workspace, ['wt.a', 'wt.b', 'wt.c']);
panels.activateTab(Slots.workspace, 'wt.a');
await f.services.commands.execute('workspace.tab.next');
expect(panels.activeTabIn(Slots.workspace), 'wt.b');
await f.services.commands.execute('workspace.tab.next');
expect(panels.activeTabIn(Slots.workspace), 'wt.c');
await f.services.commands.execute('workspace.tab.next'); // wrap forward
expect(panels.activeTabIn(Slots.workspace), 'wt.a');
await f.services.commands.execute('workspace.tab.previous'); // wrap backward
expect(panels.activeTabIn(Slots.workspace), 'wt.c');
});
test('ctrl+pagedown/up resolve to the workspace tab-cycle commands across presets (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final km = f.services.keymap.keymap;
expect((km?.resolve(KeyChord.parse('ctrl+pagedown'), const {}) as InvokeCommandIntent?)?.commandId, 'workspace.tab.next');
expect((km?.resolve(KeyChord.parse('ctrl+pageup'), const {}) as InvokeCommandIntent?)?.commandId, 'workspace.tab.previous');
});
test('workspace tab cycle is a no-op with fewer than two tabs (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final panels = f.services.panels;
panels.contribute(TabContribution(id: 'only', slot: Slots.workspace, title: 'only', build: (_) => const SizedBox.shrink()));
panels.activateTab(Slots.workspace, 'only');
final r = await f.services.commands.execute('workspace.tab.next');
expect(r.ok, isTrue);
expect(r.data['cycled'], isFalse);
expect(panels.activeTabIn(Slots.workspace), 'only');
});
});
}
+7
View File
@@ -56,6 +56,13 @@ void main() {
await tester.pump();
}
testWidgets('focusing the editor publishes editor.focused (T-406)', (tester) async {
stubOneBuffer('hello');
expect(f.services.keymap.scope['editor.focused'], isNot(true));
await pumpEditor(tester); // taps into the editor → focus
expect(f.services.keymap.scope['editor.focused'], isTrue, reason: 'pane nav guards on !editor.focused');
});
testWidgets('normal-mode x deletes the char under the caret', (tester) async {
String? sentText;
f.ipc.stub('editor.set-content', (a) async {
+97 -1
View File
@@ -22,9 +22,13 @@ void main() {
km = Keymap([KeymapLayer.fromYaml(src)]);
});
const normal = {'vim.normal': true};
// Editor-focused normal mode: j/k/h/l/gg/G/o are buffer motions here because
// the `editor.focused` flag suppresses the pane-nav bindings (T-406).
const normal = {'vim.normal': true, 'editor.focused': true};
const insert = {'vim.insert': true};
const visual = {'vim.visual': true};
// A non-editor pane focused under vim normal mode: the same keys are nav.*.
const paneNormal = {'vim.normal': true};
Intent? resolve(String chord, Map<String, bool> scope) => km.resolve(KeyChord.parse(chord), scope);
@@ -83,4 +87,96 @@ void main() {
expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending);
expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
});
group('pane navigation (T-406)', () {
test('motion keys resolve to nav.* when a non-editor pane is focused', () {
expect(resolve('j', paneNormal), isA<NavDownIntent>());
expect(resolve('k', paneNormal), isA<NavUpIntent>());
expect(resolve('h', paneNormal), isA<NavCollapseOrLeftIntent>());
expect(resolve('l', paneNormal), isA<NavExpandOrRightIntent>());
expect(resolve('ctrl+d', paneNormal), isA<NavPageDownIntent>());
expect(resolve('ctrl+u', paneNormal), isA<NavPageUpIntent>());
expect(resolve('shift+g', paneNormal), isA<NavBottomIntent>());
expect(resolve('o', paneNormal), isA<NavActivateIntent>());
expect(resolve('enter', paneNormal), isA<NavActivateIntent>());
});
test('the editor.focused guard hands the same keys to the editor', () {
// With the editor focused, nav.* is suppressed and the buffer motions win.
expect(_cmd(resolve('j', normal)), 'editor.vim.down');
expect(_cmd(resolve('h', normal)), 'editor.vim.left');
expect(_cmd(resolve('l', normal)), 'editor.vim.right');
expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd');
expect(_cmd(resolve('o', normal)), 'editor.vim.openBelow');
});
test('gg resolves to nav.top in a pane, docStart in the editor', () {
final pane = SequenceMatcher(keymap: () => km, context: () => paneNormal);
pane.feed(KeyChord.parse('g'));
expect(pane.feed(KeyChord.parse('g')).intent, isA<NavTopIntent>());
final editor = SequenceMatcher(keymap: () => km, context: () => normal);
editor.feed(KeyChord.parse('g'));
expect(_cmd(editor.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
});
test('pane nav is normal-mode only — visual mode keeps the editor motion', () {
// nav.* is guarded `vim.normal && !editor.focused`; visual mode has no
// vim.normal flag, so j stays the editor motion even without editor.focused.
expect(_cmd(resolve('j', visual)), 'editor.vim.down');
});
});
group('ctrl+w window family (T-404)', () {
SequenceMatcher matcher([Keymap? k]) => SequenceMatcher(keymap: () => k ?? km, context: () => normal, captureCounts: false);
Intent? seq(SequenceMatcher m, List<String> chords) {
SeqResult? r;
for (final c in chords) {
r = m.feed(KeyChord.parse(c));
}
return r?.intent;
}
test('ctrl+w h/l/j/o resolve to the panel commands', () {
expect(_cmd(seq(matcher(), ['ctrl+w', 'h'])), 'panel.focus.left');
expect(_cmd(seq(matcher(), ['ctrl+w', 'l'])), 'panel.focus.right');
expect(_cmd(seq(matcher(), ['ctrl+w', 'j'])), 'dock.toggle');
expect(_cmd(seq(matcher(), ['ctrl+w', 'o'])), 'panel.focusMode');
});
test('ctrl+w w and ctrl+w ctrl+w cycle panels; shift+w cycles back', () {
expect(seq(matcher(), ['ctrl+w', 'w']), isA<FocusNextPanelIntent>());
expect(seq(matcher(), ['ctrl+w', 'ctrl+w']), isA<FocusNextPanelIntent>());
expect(seq(matcher(), ['ctrl+w', 'shift+w']), isA<FocusPreviousPanelIntent>());
});
test('ctrl+w q and ctrl+w c close the editor', () {
expect(_cmd(seq(matcher(), ['ctrl+w', 'q'])), 'editor.close');
expect(_cmd(seq(matcher(), ['ctrl+w', 'c'])), 'editor.close');
});
test('bare ctrl+w is a live prefix; the timeout flush fires editor.close', () {
// editor.close's bare ctrl+w binding comes from the default-layout
// contributions layer, which sits under the preset in the real app.
final layered = Keymap([
KeymapLayer.fromYaml(File('assets/keymaps/vim.yaml').readAsStringSync()),
KeymapLayer(
name: 'contrib',
bindings: [KeymapBinding.chord(KeyChord.parse('ctrl+w'), intent: const InvokeCommandIntent('editor.close'))],
),
]);
final m = matcher(layered);
expect(m.feed(KeyChord.parse('ctrl+w')).outcome, SeqOutcome.pending);
expect(_cmd(m.flush().intent), 'editor.close'); // bare ctrl+w → close, after the wait
});
test('ctrl+w sequences need vim.normal/visual — inert under no vim scope', () {
final m = SequenceMatcher(keymap: () => km, context: () => const {}, captureCounts: false);
// With no vim scope, ctrl+w isn't a sequence prefix here, so the first
// chord doesn't pend on the family.
expect(m.feed(KeyChord.parse('ctrl+w')).outcome, isNot(SeqOutcome.fired));
expect(seq(matcher(km), ['ctrl+w', 'h']), isNotNull); // but it does under vim.normal
});
});
}
@@ -382,6 +382,99 @@ void main() {
});
});
group('FileTreeController — keyboard selection (T-406)', () {
// Tree: '' (root) → [lib/ (→ app.dart), main.dart]
Future<FileTreeController> tree({bool expandLib = false}) async {
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
f.ipc.stub('files.watch', (_) async => _ok(const {}));
f.ipc.stub('files.ls', (args) async {
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')],
});
}
if (path == 'lib') {
return _ok({
'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')],
});
}
return _ok({'entries': <Object?>[]});
});
final c = makeCtrl();
await c.load();
if (expandLib) await c.toggle('lib');
return c;
}
test('visibleNodes flattens the root + expanded children in render order', () async {
final c = await tree(expandLib: true);
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'lib/app.dart', 'main.dart']);
expect(c.visibleNodes().map((n) => n.depth), [0, 1, 2, 1]);
});
test('a collapsed directory hides its children from the visible list', () async {
final c = await tree();
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'main.dart']);
});
test('moveSelection walks the visible list and clamps at the ends', () async {
final c = await tree(expandLib: true);
expect(c.selectedPath, isNull);
c.moveSelection(1);
expect(c.selectedPath, ''); // first move lands on the root
c.moveSelection(1);
expect(c.selectedPath, 'lib');
c.moveSelection(2);
expect(c.selectedPath, 'main.dart'); // lib/app.dart skipped over by +2
c.moveSelection(5); // clamp at the bottom
expect(c.selectedPath, 'main.dart');
c.moveSelection(-100); // clamp at the top
expect(c.selectedPath, '');
});
test('selectEdge jumps to the first / last visible row (gg / G)', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: false);
expect(c.selectedPath, 'main.dart');
c.selectEdge(top: true);
expect(c.selectedPath, '');
});
test('expandOrInto expands a collapsed dir, then steps into its first child', () async {
final c = await tree();
c.moveSelection(1); // root
c.moveSelection(1); // lib (collapsed)
expect(c.isExpanded('lib'), isFalse);
await c.expandOrInto(); // expands
expect(c.isExpanded('lib'), isTrue);
expect(c.selectedPath, 'lib'); // selection stays on the dir
await c.expandOrInto(); // steps into first child
expect(c.selectedPath, 'lib/app.dart');
});
test('collapseOrOut collapses an expanded dir, then steps out to the parent', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: true);
c.moveSelection(2); // lib/app.dart
expect(c.selectedPath, 'lib/app.dart');
await c.collapseOrOut(); // a file → step to parent
expect(c.selectedPath, 'lib');
await c.collapseOrOut(); // an expanded dir → collapse in place
expect(c.isExpanded('lib'), isFalse);
expect(c.selectedPath, 'lib');
});
test('activateTarget reports the selected row as dir-or-file for the view', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: true);
c.moveSelection(1); // lib
expect(c.activateTarget(), (isDirectory: true, path: 'lib'));
c.moveSelection(2); // main.dart
expect(c.activateTarget(), (isDirectory: false, path: 'main.dart'));
});
});
group('FileTreeController — dispose()', () {
test('dispose cancels event subscription without error', () async {
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
+135
View File
@@ -0,0 +1,135 @@
/// Widget tests for keyboard navigation in the file tree (T-406): under the vim
/// preset a focused tree moves a selection cursor with j/k, expands with l, and
/// opens the selected file with o/enter — driving the FileTreeController through
/// PaneKeyNav.
library;
import 'package:clide/builtin/files/src/file_tree_view.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
Map<String, Object?> _entry(String name, String path, {bool dir = false}) => {
'name': name,
'path': path,
'isDirectory': dir,
'isSymlink': false,
'sizeBytes': 0,
'modifiedMs': 0,
};
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
// Tree: /repo → [lib/ (→ app.dart), main.dart].
void stubTree() {
f.ipc.stub('files.root', (_) async => _ok({'path': '/repo'}));
f.ipc.stub('files.watch', (_) async => _ok(const {}));
f.ipc.stub('files.ls', (args) async {
final path = args['path'] as String? ?? '';
if (path == '') {
return _ok({
'entries': [_entry('lib', 'lib', dir: true), _entry('main.dart', 'main.dart')],
});
}
if (path == 'lib') {
return _ok({
'entries': [_entry('app.dart', 'lib/app.dart')],
});
}
return _ok({'entries': <Object?>[]});
});
}
Future<void> mountFocused(WidgetTester tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await tester.pumpWidget(harness(f, const FileTreeView()));
await pumpAsync(tester);
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
node.requestFocus();
await tester.pump();
}
testWidgets('j moves the selection and o opens the selected file (T-406)', (tester) async {
stubTree();
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return _ok(const {});
});
await mountFocused(tester);
// visible: '' (root), 'lib', 'main.dart'. j×3 lands on main.dart.
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
await pumpAsync(tester);
expect(opened, ['main.dart']);
});
testWidgets('l expands the selected directory, h collapses it (T-406)', (tester) async {
stubTree();
await mountFocused(tester);
expect(find.text('app.dart'), findsNothing); // lib collapsed
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // root
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // lib
await tester.sendKeyEvent(LogicalKeyboardKey.keyL); // expand
await tester.pump();
await pumpAsync(tester);
expect(find.text('app.dart'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH); // collapse lib
await tester.pump();
await pumpAsync(tester);
expect(find.text('app.dart'), findsNothing);
});
testWidgets('G/gg/k and ctrl+d/u move the cursor; o on a dir toggles it (T-406)', (tester) async {
stubTree();
await mountFocused(tester);
// G → last visible row (main.dart), o → main.dart is a file → opens it.
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return _ok(const {});
});
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG); // G → bottom
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK); // up → lib
await tester.pump();
// o on the 'lib' directory toggles (expands) it rather than opening a file.
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
await pumpAsync(tester);
expect(find.text('app.dart'), findsOneWidget); // lib expanded, no file opened
expect(opened, isEmpty);
// gg → top, then ctrl+d / ctrl+u exercise the half-page paths.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
// No crash, selection stayed in bounds — the dispatch paths ran.
expect(opened, isEmpty);
});
}
@@ -0,0 +1,93 @@
/// TerminalPane lifecycle tests.
///
/// T-366: disposing the pane must send `pane.close` for its backend
/// pane. The pre-fix code looked the kernel up from dispose() — an
/// illegal ancestor lookup whose throw was swallowed — so the close
/// was never sent and the backend PTY + daemon pane leaked.
library;
import 'dart:io';
import 'package:clide/builtin/terminal/src/terminal_pane.dart';
import 'package:clide/clide.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture fixture;
setUp(() async {
fixture = await KernelFixture.create();
});
tearDown(() async {
await fixture.dispose();
});
testWidgets('disposing the pane sends pane.close for the spawned pane', (tester) async {
final closed = <String>[];
fixture.ipc.setConnected(true);
fixture.ipc.stub('pane.spawn', (args) async => IpcResponse.ok(id: 'r1', data: {'id': 'pane-7', 'pid': 4321}));
fixture.ipc.stub('pane.close', (args) async {
closed.add(args['id'] as String);
return IpcResponse.ok(id: 'r2');
});
await tester.pumpWidget(harness(fixture, const TerminalPane()));
// First pump runs the post-frame spawn; second flushes its await.
await pumpAsync(tester);
expect(find.textContaining('pane-7'), findsOneWidget, reason: 'spawn should complete and surface the pane id');
// Tear the tree down — Overlay keeps its initialEntries across
// rebuilds, so swapping the harness child would NOT dispose the
// pane; unmounting the whole tree does. State.dispose() must fire
// pane.close.
await tester.pumpWidget(const SizedBox());
await pumpAsync(tester);
expect(closed, ['pane-7']);
});
testWidgets('spawns the shell in the open workspace, not Directory.current (T-381)', (tester) async {
String? spawnedCwd;
fixture.ipc.setConnected(true);
fixture.ipc.stub('pane.spawn', (args) async {
spawnedCwd = args['cwd'] as String?;
return IpcResponse.ok(id: 'r1', data: {'id': 'pane-9', 'pid': 1});
});
// Open a project so the kernel has a workspace root.
final repo = await tester.runAsync(() async {
final dir = fixture.tempDir.createTempSync('repo-');
Directory('${dir.path}/.git').createSync();
return dir;
});
final opened = await tester.runAsync(() => fixture.services.project.open(repo!.path));
expect(opened, isTrue);
await tester.pumpWidget(harness(fixture, const TerminalPane()));
await pumpAsync(tester);
expect(spawnedCwd, repo!.path);
expect(spawnedCwd, isNot(Directory.current.path));
});
testWidgets('disposing before spawn completes sends no close', (tester) async {
final closed = <String>[];
fixture.ipc.setConnected(false); // spawn bails out: no pane id
fixture.ipc.stub('pane.close', (args) async {
closed.add(args['id'] as String);
return IpcResponse.ok(id: 'r1');
});
await tester.pumpWidget(harness(fixture, const TerminalPane()));
await pumpAsync(tester);
await tester.pumpWidget(const SizedBox());
await pumpAsync(tester);
expect(closed, isEmpty);
});
}
+4
View File
@@ -46,6 +46,10 @@ void main() {
expect(find.text('clide'), findsOneWidget);
expect(find.text('IDE for Claude Code CLI'), findsOneWidget);
expect(find.text('Open folder…'), findsOneWidget);
// T-383: no advertised dead ends — these tiles were inert no-ops with
// unregistered shortcuts; they return only with working flows.
expect(find.text('Clone from git…'), findsNothing);
expect(find.text('Start a Claude session'), findsNothing);
});
testWidgets('TIPS card renders when the viewport is tall enough', (tester) async {
+23 -1
View File
@@ -11,6 +11,7 @@ void main() {
late Directory sandbox;
late DaemonDispatcher dispatcher;
late FilesService files;
late RecordingEventSink sink;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-files-test-');
@@ -22,7 +23,7 @@ void main() {
Directory('${sandbox.path}/.dart_tool').createSync();
File('${sandbox.path}/.dart_tool/hidden').writeAsStringSync('x');
final sink = RecordingEventSink();
sink = RecordingEventSink();
files = FilesService(
root: sandbox,
events: sink,
@@ -229,6 +230,27 @@ void main() {
// assertion is covered in test/files/watcher_test.dart.
});
test('shutdown stops the watcher delivering into the bus (T-367)', () async {
// Project switch tears the old workspace's services down; a leaked
// watcher would keep emitting the OLD workspace's events into the
// new one's bus.
final ack = await call('files.watch', const {});
expect(ack.ok, isTrue);
await Future<void>.delayed(const Duration(milliseconds: 50));
await files.shutdown();
final before = sink.ofKind('files.changed').length;
await File('${sandbox.path}/after-shutdown.txt').writeAsString('x');
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(sink.ofKind('files.changed').length, before, reason: 'no events after shutdown');
});
test('shutdown is idempotent and re-watch works after it', () async {
await files.shutdown();
await files.shutdown();
final r = await call('files.watch', const {});
expect(r.ok, isTrue);
});
test('FilesService.atCwd walks parent dirs looking for .git, falls back to CWD if none', () async {
final deepNoGit = await Directory.systemTemp.createTemp('clide-no-git-');
addTearDown(() => deepNoGit.deleteSync(recursive: true));
+15 -1
View File
@@ -11,13 +11,14 @@ void main() {
late Directory dir;
late RecordingEventSink sink;
late DaemonDispatcher d;
late SearchService service;
setUp(() async {
dir = await Directory.systemTemp.createTemp('clide-search-cmd-');
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);
service = SearchService(root: dir, ignore: IgnoreSet([]), events: sink, useIsolates: false);
d = DaemonDispatcher();
registerSearchCommands(d, service);
});
@@ -78,6 +79,19 @@ void main() {
expect(r.data['cancelled'], 'search-0');
});
test('shutdown cancels in-flight searches and is idempotent (T-367)', () async {
// Subscribe before dispatching — the done event is broadcast.
final doneFuture = sink.stream.firstWhere((e) => e.kind == 'search.done');
final r = await call('search.grep', const {'pattern': 'answer'});
expect(r.ok, isTrue);
await service.shutdown();
await service.shutdown();
// The search still terminates (cancelled or already complete,
// depending on timing) — shutdown must not wedge the stream.
final done = await doneFuture;
expect(done.data['searchId'], r.data['searchId']);
});
test('search.replace preview reports edits without touching disk', () async {
final r = await call('search.replace', const {'pattern': 'answer', 'replacement': 'result'});
expect(r.ok, isTrue);
+43
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/src/editor/registry.dart';
import 'package:clide/src/files/path_safety.dart' show PathOutsideRoot;
import 'package:test/test.dart';
void main() {
@@ -212,4 +213,46 @@ void main() {
expect(changed.map((e) => e.data['id']), contains(readme.id));
});
});
// T-363: editor.open/save returned absolute paths verbatim and did no
// `..` normalization — an unconfined read AND write primitive over IPC
// while files.read was carefully guarded.
group('path confinement (T-363)', () {
test('open rejects .. traversal out of the workspace', () async {
final outside = await Directory.systemTemp.createTemp('clide-editor-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
await File('${outside.path}/secret.txt').writeAsString('secret');
final escape = '../${outside.path.split('/').last}/secret.txt';
await expectLater(reg.open(escape), throwsA(isA<PathOutsideRoot>()));
});
test('open rejects absolute paths outside the workspace', () async {
await expectLater(reg.open('/etc/hostname'), throwsA(isA<PathOutsideRoot>()));
});
test('open accepts an absolute path inside the workspace', () async {
final buf = await reg.open('${sandbox.path}/README.md');
expect(buf.content, contains('Hello'));
});
test('open rejects a symlink pointing outside the workspace', () async {
final outside = await Directory.systemTemp.createTemp('clide-editor-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
await File('${outside.path}/secret.txt').writeAsString('secret');
Link('${sandbox.path}/sneaky').createSync('${outside.path}/secret.txt');
await expectLater(reg.open('sneaky'), throwsA(isA<PathOutsideRoot>()));
});
test('save rejects a buffer whose path now symlinks outside', () async {
// Open a legitimate file, then swap a symlink in under its path.
final buf = await reg.open('victim.txt');
reg.setContent(buf.id, 'attacker-controlled');
final outside = await Directory.systemTemp.createTemp('clide-editor-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
await File('${outside.path}/target.txt').writeAsString('original');
Link('${sandbox.path}/victim.txt').createSync('${outside.path}/target.txt');
await expectLater(reg.save(buf.id), throwsA(isA<PathOutsideRoot>()));
expect(await File('${outside.path}/target.txt').readAsString(), 'original');
});
});
}
+42
View File
@@ -56,4 +56,46 @@ void main() {
expect(r.files, isEmpty);
expect(r.truncated, isFalse);
});
// T-365: stat() follows links, so the old detection (stat.type == link)
// was always false and walkFiles descended symlinked directories —
// an escape hatch out of the workspace.
group('symlinks (T-365)', () {
test('listDir reports a symlinked directory as a symlink', () async {
final outside = await Directory.systemTemp.createTemp('clide-walk-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
File('${outside.path}/secret.txt').writeAsStringSync('s');
Link('${root.path}/linked').createSync(outside.path);
final entries = await listDir(root: root, dir: '', ignore: IgnoreSet([]));
final linked = entries.singleWhere((e) => e.name == 'linked');
expect(linked.isSymlink, isTrue);
expect(linked.isDirectory, isTrue, reason: 'target type still reported for the UI');
});
test('walkFiles does not descend a symlinked directory', () async {
final outside = await Directory.systemTemp.createTemp('clide-walk-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
File('${outside.path}/secret.txt').writeAsStringSync('s');
Link('${root.path}/linked').createSync(outside.path);
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
expect(r.files.map((e) => e.path), isNot(contains('linked/secret.txt')));
});
test('a symlink cycle does not hang the walk', () async {
Link('${root.path}/lib/loop').createSync(root.path);
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
expect(r.truncated, isFalse);
expect(r.files.map((e) => e.path), contains('README.md'));
});
test('a symlink to a file is emitted as a file entry, flagged', () async {
Link('${root.path}/readme-link').createSync('${root.path}/README.md');
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
final e = r.files.singleWhere((e) => e.path == 'readme-link');
expect(e.isSymlink, isTrue);
expect(e.isDirectory, isFalse);
});
});
}
+15 -225
View File
@@ -1,251 +1,41 @@
/// Tests for the shared git plumbing in operations.dart. The legacy
/// free-function operation API (gitStage/gitCommit/...) was removed in
/// the T-385 dead-code sweep — it duplicated GitClient verb-for-verb
/// with zero non-test callers; GitClient's own tests cover the verbs.
library;
import 'dart:io';
import 'package:clide/src/git/operations.dart';
import 'package:test/test.dart';
void main() {
late Directory sandbox;
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 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);
});
tearDown(() async {
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
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);
expect((r.stdout as String).trim(), 'new.txt');
});
test('gitUnstage unstages a file', () async {
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);
expect((r.stdout as String).trim(), isEmpty);
});
test('gitCommit creates a commit', () async {
await File('${sandbox.path}/c.txt').writeAsString('commit me');
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);
expect((r.stdout as String).trim(), 'test commit');
});
test('gitCommit with nothing staged throws', () async {
expect(() => gitCommit(sandbox, 'empty'), throwsA(isA<GitException>()));
});
test('gitLog returns entries', () async {
final entries = await gitLog(sandbox);
expect(entries, hasLength(1));
expect(entries.first.subject, 'init');
expect(entries.first.hash, hasLength(40));
});
test('gitDiscard restores a file', () async {
await File('${sandbox.path}/file.txt').writeAsString('changed');
await gitDiscard(sandbox, ['file.txt']);
final content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'hello\n');
});
test('gitStash and gitStashPop round-trip', () async {
await File('${sandbox.path}/file.txt').writeAsString('stashed');
await gitStash(sandbox);
var content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'hello\n');
await gitStashPop(sandbox);
content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'stashed');
});
test('gitCurrentBranch returns branch name', () async {
final branch = await gitCurrentBranch(sandbox);
expect(branch, isNotNull);
});
test('GitException.toString includes the message', () {
const e = GitException('boom');
expect(e.toString(), contains('boom'));
expect(const GitException('boom').toString(), contains('boom'));
});
test('GitLogEntry.toJson serialises every field (body omitted when empty)', () {
const a = GitLogEntry(hash: 'h', shortHash: 's', subject: 'sub', author: 'a', date: 'd');
expect(a.toJson().containsKey('body'), isFalse);
expect(a.toJson(), {'hash': 'h', 'shortHash': 's', 'subject': 'sub', 'author': 'a', 'date': 'd'});
const b = GitLogEntry(hash: 'h', shortHash: 's', subject: 'sub', author: 'a', date: 'd', body: 'bd');
expect(b.toJson()['body'], 'bd');
});
test('gitStage with a bogus path throws GitException', () async {
try {
await gitStage(sandbox, ['no-such-file-here']);
fail('expected GitException');
} on GitException catch (_) {}
});
test('gitUnstage with no paths unstages everything', () async {
await File('${sandbox.path}/a.txt').writeAsString('x');
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);
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 patch = patchResult.stdout as String;
await gitStageHunk(sandbox, patch);
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);
expect((cleared.stdout as String).trim(), isEmpty);
});
test('_applyPatch surfaces stderr in the GitException on a bad patch', () async {
try {
await gitStageHunk(sandbox, 'not a valid patch\n');
fail('expected GitException');
} on GitException catch (e) {
expect(e.stderr, isNotEmpty);
}
});
test('gitBranches lists branches and marks the current one', () async {
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
final branches = await gitBranches(sandbox);
final names = branches.map((b) => b.name).toList();
expect(names, containsAll(['feature/a']));
expect(branches.any((b) => b.current), isTrue);
});
test('gitBranches returns empty on a non-git directory', () async {
final notGit = await Directory.systemTemp.createTemp('clide-git-not-');
addTearDown(() => notGit.deleteSync(recursive: true));
expect(await gitBranches(notGit), isEmpty);
});
test('gitCheckout switches branches; an unknown branch throws', () async {
await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path);
await gitCheckout(sandbox, 'next');
expect(await gitCurrentBranch(sandbox), 'next');
try {
await gitCheckout(sandbox, 'does-not-exist');
fail('expected GitException');
} on GitException catch (_) {}
});
test('gitPull + gitPush round-trip against a local bare remote', () async {
final remote = await Directory.systemTemp.createTemp('clide-git-remote-');
addTearDown(() => remote.deleteSync(recursive: true));
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
final pushOut = await gitPush(sandbox, remote: 'origin', branch: 'main', setUpstream: true);
expect(pushOut, isNotEmpty);
// Clone elsewhere and pull on the original. Cheaper: just call gitPull
// and confirm it doesn't throw (already up-to-date).
final pullOut = await gitPull(sandbox);
expect(pullOut, isA<String>());
});
test('gitPush against no remote throws GitException', () async {
try {
await gitPush(sandbox);
fail('expected GitException');
} on GitException catch (_) {}
});
test('gitPush rejects a -prefixed remote (argv-injection guard)', () async {
try {
await gitPush(sandbox, remote: '--upload-pack=evil', branch: 'main');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('remote'));
}
});
test('gitPush rejects a -prefixed branch', () async {
try {
await gitPush(sandbox, remote: 'origin', branch: '--exec=evil');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('branch'));
}
});
test('gitCheckout rejects a -prefixed branch', () async {
try {
await gitCheckout(sandbox, '--upload-pack=evil');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('branch'));
}
});
test('gitCheckout rejects an empty branch', () async {
try {
await gitCheckout(sandbox, '');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('branch'));
}
});
test('validateGitRef accepts plain refs', () {
expect(() => validateGitRef('main', kind: 'branch'), returnsNormally);
expect(() => validateGitRef('feature/foo', kind: 'branch'), returnsNormally);
expect(() => validateGitRef('origin', kind: 'remote'), returnsNormally);
});
test('gitPull against no remote throws GitException', () async {
try {
await gitPull(sandbox);
fail('expected GitException');
} on GitException catch (_) {}
test('validateGitRef rejects empty and -prefixed values (argv-injection guard)', () {
expect(() => validateGitRef(null, kind: 'branch'), throwsA(isA<GitException>()));
expect(() => validateGitRef('', kind: 'branch'), throwsA(isA<GitException>()));
expect(() => validateGitRef('--upload-pack=evil', kind: 'remote'), throwsA(isA<GitException>()));
});
test('gitLog returns empty on a non-git directory', () async {
final notGit = await Directory.systemTemp.createTemp('clide-git-log-');
addTearDown(() => notGit.deleteSync(recursive: true));
expect(await gitLog(notGit), isEmpty);
});
test('gitCurrentBranch returns null on a non-git directory', () async {
final notGit = await Directory.systemTemp.createTemp('clide-git-cb-');
addTearDown(() => notGit.deleteSync(recursive: true));
expect(await gitCurrentBranch(notGit), isNull);
});
test('gitStashPop on an empty stash throws GitException', () async {
try {
await gitStashPop(sandbox);
fail('expected GitException');
} on GitException catch (_) {}
});
test('gitDiscard with an empty list returns without invoking git', () async {
// Empty list short-circuits before the subprocess call; just verify
// it doesn't throw.
await gitDiscard(sandbox, const []);
});
test('gitBin resolves to a usable binary path', () {
test('gitBin resolves to a runnable git', () async {
expect(gitBin, isNotEmpty);
final r = await Process.run(gitBin, ['--version']);
expect(r.exitCode, 0);
});
}
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:clide/kernel/kernel.dart';
/// A DaemonClient that doesn't actually open a socket. Use in tests
/// that need a connected-state observable but not a real daemon.
class FakeDaemonClient extends DaemonClient {
FakeDaemonClient({required super.log, required super.events}) : super(socketPath: '/dev/null/fake-clide.sock');
FakeDaemonClient({required super.log, required super.events}) : super.unixSocket(socketPath: '/dev/null/fake-clide.sock');
bool _fakeConnected = false;
final Map<String, Future<IpcResponse> Function(Map<String, Object?>)> _stubs = {};
+52 -3
View File
@@ -31,10 +31,12 @@ void main() {
if (discoveryDir.existsSync()) discoveryDir.deleteSync(recursive: true);
});
Future<HttpClientResponse> openSse() async {
Future<HttpClientResponse> openSse({String? token}) async {
final client = HttpClient();
addTearDown(client.close);
final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse'));
final t = token ?? server.authToken;
if (t != null) req.headers.set(kMcpAuthHeader, t);
return req.close();
}
@@ -64,6 +66,7 @@ void main() {
addTearDown(client.close);
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId'));
req.headers.contentType = ContentType.json;
req.headers.set(kMcpAuthHeader, server.authToken!);
req.write(jsonEncode(body));
final resp = await req.close();
expect(resp.statusCode, HttpStatus.accepted);
@@ -93,11 +96,49 @@ void main() {
final client = HttpClient();
addTearDown(client.close);
final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/no-such-thing'));
req.headers.set(kMcpAuthHeader, server.authToken!);
final resp = await req.close();
expect(resp.statusCode, HttpStatus.notFound);
});
});
// T-362: D-71's "another user on this host must not drive my IDE" is
// enforced with 0600 on the unix socket — the HTTP port must not bypass it.
group('McpServer (T-362) auth token', () {
test('the lock file publishes the auth token, mode 600', () async {
final lock = File(server.lockFilePath!);
final payload = jsonDecode(lock.readAsStringSync()) as Map<String, Object?>;
expect(payload['authToken'], server.authToken);
expect((server.authToken ?? '').length, greaterThanOrEqualTo(32));
final mode = lock.statSync().mode & 0xFFF;
expect(mode, 0x180, reason: 'lock file must be 0600 — it carries the token');
});
test('a request without the token is rejected with 401', () async {
final client = HttpClient();
addTearDown(client.close);
final sse = await (await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse'))).close();
expect(sse.statusCode, HttpStatus.unauthorized);
final post = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=s0'));
post.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
final resp = await post.close();
expect(resp.statusCode, HttpStatus.unauthorized);
});
test('a request with a wrong token is rejected with 401', () async {
final resp = await openSse(token: 'not-the-token');
expect(resp.statusCode, HttpStatus.unauthorized);
});
test('the token rotates per start', () async {
final first = server.authToken;
await server.stop();
await server.start();
expect(server.authToken, isNot(first));
});
});
group('McpServer (T-130) JSON-RPC', () {
test('SSE opens with an endpoint event carrying the session id', () async {
final (sessionId, events) = await connectAndCaptureEndpoint();
@@ -172,6 +213,7 @@ void main() {
addTearDown(client.close);
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=ghost'));
req.headers.contentType = ContentType.json;
req.headers.set(kMcpAuthHeader, server.authToken!);
req.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
final resp = await req.close();
expect(resp.statusCode, HttpStatus.notFound);
@@ -183,6 +225,7 @@ void main() {
addTearDown(client.close);
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId'));
req.headers.contentType = ContentType.json;
req.headers.set(kMcpAuthHeader, server.authToken!);
req.write('{not json');
final resp = await req.close();
expect(resp.statusCode, HttpStatus.badRequest);
@@ -224,7 +267,9 @@ void main() {
Future<(String, Stream<String>)> connect() async {
final client = HttpClient();
addTearDown(client.close);
final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close();
final sseReq = await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'));
sseReq.headers.set(kMcpAuthHeader, srv.authToken!);
final resp = await sseReq.close();
final dataLines = resp
.transform(utf8.decoder)
.transform(const LineSplitter())
@@ -246,6 +291,7 @@ void main() {
addTearDown(client.close);
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid'));
req.headers.contentType = ContentType.json;
req.headers.set(kMcpAuthHeader, srv.authToken!);
req.write(jsonEncode(body));
final resp = await req.close();
expect(resp.statusCode, HttpStatus.accepted);
@@ -306,7 +352,9 @@ void main() {
});
final client = HttpClient();
addTearDown(client.close);
final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close();
final sseReq = await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'));
sseReq.headers.set(kMcpAuthHeader, srv.authToken!);
final resp = await sseReq.close();
final data = resp
.transform(utf8.decoder)
.transform(const LineSplitter())
@@ -323,6 +371,7 @@ void main() {
final replyFuture = data.firstWhere((s) => s.contains('"id":13'));
final post = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid'));
post.headers.contentType = ContentType.json;
post.headers.set(kMcpAuthHeader, srv.authToken!);
post.write(
jsonEncode({
'jsonrpc': '2.0',
+49
View File
@@ -201,6 +201,55 @@ void main() {
await c.close();
});
// T-372: the old async onData never paused its subscription, so
// pipelined requests interleaved mid-handler; per-chunk decode also
// corrupted runes split across socket writes.
test('two requests pipelined in one write are handled serially, in order (T-372/D-72)', () async {
final order = <String>[];
dispatcher.register('slow', (req) async {
order.add('${req.id}:start');
await Future<void>.delayed(const Duration(milliseconds: 50));
order.add('${req.id}:end');
return IpcResponse.ok(id: req.id);
});
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final c = await Socket.connect(InternetAddress(server.socketPath, type: InternetAddressType.unix), 0);
// Single write carrying both frames.
c.write('${IpcRequest(id: 'p1', cmd: 'slow').encode()}\n${IpcRequest(id: 'p2', cmd: 'slow').encode()}\n');
await c.flush();
final replies = c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
final got = await replies.take(2).toList().timeout(const Duration(seconds: 5));
await c.close();
expect((IpcMessage.decode(got[0]) as IpcResponse).id, 'p1');
expect((IpcMessage.decode(got[1]) as IpcResponse).id, 'p2');
expect(order, ['p1:start', 'p1:end', 'p2:start', 'p2:end'], reason: 'D-72: dispatch is serial, never interleaved');
});
test('a request split mid-UTF-8-rune across two writes decodes intact (T-372)', () async {
String? gotText;
dispatcher.register('echo', (req) async {
gotText = req.args['text'] as String?;
return IpcResponse.ok(id: req.id, data: {'echo': gotText});
});
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final c = await Socket.connect(InternetAddress(server.socketPath, type: InternetAddressType.unix), 0);
final frame = utf8.encode('${IpcRequest(id: 'u1', cmd: 'echo', args: const {'text': 'héllo — ünïcode'}).encode()}\n');
// Split inside the multi-byte 'é' (the first non-ASCII rune).
final cut = frame.indexWhere((b) => b > 0x7f) + 1;
c.add(frame.sublist(0, cut));
await c.flush();
await Future<void>.delayed(const Duration(milliseconds: 30));
c.add(frame.sublist(cut));
await c.flush();
final line = await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
await c.close();
final reply = IpcMessage.decode(line) as IpcResponse;
expect(reply.ok, isTrue);
expect(gotText, 'héllo — ünïcode', reason: 'persistent decoder must join the split rune');
});
test('socketPath returns the resolved path before start (no bind)', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
// Before start, the getter falls back to workspaceSocketPath; it
+73
View File
@@ -0,0 +1,73 @@
/// Tests for `lib/src/ipc/transport.dart` (T-331) — the DaemonTransport
/// seam. Runs under plain `dart test` (core suite): no Flutter imports.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/src/ipc/transport.dart';
import 'package:test/test.dart';
void main() {
group('LocalSocketTransport', () {
late Directory dir;
late String path;
late ServerSocket server;
setUp(() async {
dir = await Directory.systemTemp.createTemp('clide-transport-');
path = '${dir.path}/sock';
server = await ServerSocket.bind(InternetAddress(path, type: InternetAddressType.unix), 0);
});
tearDown(() async {
await server.close();
await dir.delete(recursive: true);
});
test('endpoint reports the socket path', () {
expect(LocalSocketTransport(path).endpoint, path);
});
test('open connects; lines round-trip both directions', () async {
final accepted = Completer<Socket>();
server.listen((s) => accepted.complete(s));
final conn = await LocalSocketTransport(path).open();
final serverSide = await accepted.future;
// client -> server
final serverLines = serverSide.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
final firstLine = serverLines.first;
conn.writeLine('{"hello":1}');
expect(await firstLine.timeout(const Duration(seconds: 2)), '{"hello":1}');
// server -> client
final clientLine = conn.lines.first;
serverSide.writeln('{"world":2}');
expect(await clientLine.timeout(const Duration(seconds: 2)), '{"world":2}');
await conn.close();
await serverSide.close();
});
test('open throws when nothing is bound (caller owns retry)', () async {
final t = LocalSocketTransport('${dir.path}/no-such.sock');
await expectLater(t.open(), throwsA(isA<SocketException>()));
});
test('lines closes when the server drops the connection', () async {
final accepted = Completer<Socket>();
server.listen((s) => accepted.complete(s));
final conn = await LocalSocketTransport(path).open();
final serverSide = await accepted.future;
final done = conn.lines.drain<void>();
await serverSide.close();
await done.timeout(const Duration(seconds: 2));
await conn.close();
});
});
}
@@ -315,6 +315,96 @@ void main() {
expect(ctx.project, same(f.services.project));
expect(ctx.ipc, same(f.services.ipc));
});
// T-377: activation is transactional, deactivation respects dependents,
// and duplicate contribution ids are rejected, not silently clobbered.
group('lifecycle hardening (T-377)', () {
test('a throw mid-contribution unwinds everything already mounted', () async {
// The tab mounts first, then the duplicate command id throws.
f.services.commands.register(
CommandContribution(
id: 'taken',
command: 'taken.cmd',
run: (_) async => IpcResponse.ok(id: ''),
),
);
f.services.extensions.register(
_Ext(
id: 'half-mounts',
contributions: [
TabContribution(id: 'half.view', slot: Slots.workspace, title: 'T', build: (_) => const SizedBox.shrink()),
CommandContribution(
id: 'half.cmd',
command: 'taken.cmd',
run: (_) async => IpcResponse.ok(id: ''),
),
],
),
);
await f.services.extensions.activateAll();
expect(f.services.extensions.isActivated('half-mounts'), isFalse);
expect(f.services.extensions.didFail('half-mounts'), isTrue);
expect(f.services.panels.hasContribution('half.view'), isFalse, reason: 'the mounted tab must be unwound');
});
test('a failed activation can retry cleanly without double-applying', () async {
var attempts = 0;
f.services.extensions.register(
_Ext(
id: 'flaky',
contributions: [TabContribution(id: 'flaky.view', slot: Slots.workspace, title: 'T', build: (_) => const SizedBox.shrink())],
onActivate: (_) async {
attempts++;
if (attempts == 1) throw StateError('first attempt fails');
},
),
);
await f.services.extensions.activateAll();
expect(f.services.extensions.didFail('flaky'), isTrue);
await f.services.extensions.activate('flaky');
expect(f.services.extensions.isActivated('flaky'), isTrue);
expect(f.services.extensions.didFail('flaky'), isFalse);
expect(f.services.panels.tabsFor(Slots.workspace).where((t) => t.id == 'flaky.view'), hasLength(1), reason: 'exactly one mount after the retry');
});
test('deactivate refuses while an active extension depends on it', () async {
f.services.extensions
..register(_Ext(id: 'base'))
..register(_Ext(id: 'leaf', dependsOn: const ['base']));
await f.services.extensions.activateAll();
await f.services.extensions.deactivate('base');
expect(f.services.extensions.isActivated('base'), isTrue, reason: 'refused: leaf still depends on base');
await f.services.extensions.deactivate('leaf');
await f.services.extensions.deactivate('base');
expect(f.services.extensions.isActivated('base'), isFalse, reason: 'allowed once the dependent is gone');
});
test('a duplicate contribution id fails the second activation', () async {
f.services.extensions
..register(
_Ext(
id: 'first',
contributions: [TabContribution(id: 'shared.view', slot: Slots.workspace, title: 'A', build: (_) => const SizedBox.shrink())],
),
)
..register(
_Ext(
id: 'second',
contributions: [TabContribution(id: 'shared.view', slot: Slots.workspace, title: 'B', build: (_) => const SizedBox.shrink())],
),
);
await f.services.extensions.activateAll();
expect(f.services.extensions.isActivated('first'), isTrue);
expect(f.services.extensions.isActivated('second'), isFalse);
expect(f.services.extensions.didFail('second'), isTrue);
expect(f.services.panels.tabsFor(Slots.workspace).where((t) => t.id == 'shared.view'), hasLength(1), reason: 'first-wins, no clobber');
});
});
});
}
+97 -1
View File
@@ -56,13 +56,50 @@ Future<String> _tmpSocket() async {
}
DaemonClient _build(String socketPath, DaemonBus bus) {
return DaemonClient(
return DaemonClient.unixSocket(
socketPath: socketPath,
log: Logger(minLevel: LogLevel.error, sinks: const []),
events: bus,
);
}
/// In-memory transport (T-331): proves DaemonClient runs unmodified over
/// any [DaemonTransport], not just the unix socket — the seam the remote
/// backend (T-329) slots into.
class _MemoryTransport implements DaemonTransport {
final toClient = StreamController<String>.broadcast();
final fromClient = StreamController<String>.broadcast();
int opens = 0;
@override
String get endpoint => 'memory://test';
@override
Future<DaemonConnection> open() async {
opens++;
return _MemoryConnection(this);
}
Future<void> close() async {
await toClient.close();
await fromClient.close();
}
}
class _MemoryConnection implements DaemonConnection {
_MemoryConnection(this._t);
final _MemoryTransport _t;
@override
Stream<String> get lines => _t.toClient.stream;
@override
void writeLine(String line) => _t.fromClient.add(line);
@override
Future<void> close() async {}
}
void main() {
group('DaemonClient — happy path', () {
test('connect → request → matching response completes', () async {
@@ -341,4 +378,63 @@ void main() {
expect(resp.ok, isTrue);
});
});
group('DaemonClient — transport seam (T-331)', () {
test('request/response round-trips over a non-socket transport', () async {
final transport = _MemoryTransport();
addTearDown(transport.close);
final bus = DaemonBus();
addTearDown(bus.dispose);
final client = DaemonClient(
transport: transport,
log: Logger(minLevel: LogLevel.error, sinks: const []),
events: bus,
);
addTearDown(client.dispose);
await client.start();
expect(transport.opens, 1);
expect(client.isConnected, isTrue);
expect(client.socketPath, 'memory://test');
final lineFuture = transport.fromClient.stream.first;
final respFuture = client.request('ping', args: {'n': 1});
final line = await lineFuture;
final req = IpcMessage.decode(line) as IpcRequest;
expect(req.cmd, 'ping');
transport.toClient.add(IpcResponse.ok(id: req.id, data: const {'pong': true}).encode());
final resp = await respFuture.timeout(const Duration(seconds: 2));
expect(resp.ok, isTrue);
expect(resp.data['pong'], isTrue);
});
test('reconnectWith swaps from a socket transport to another transport', () async {
final path = await _tmpSocket();
final daemon = _TestDaemon(path);
await daemon.start();
addTearDown(daemon.close);
final bus = DaemonBus();
addTearDown(bus.dispose);
final client = _build(path, bus);
addTearDown(client.dispose);
await client.start();
await daemon.waitForClient();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(client.isConnected, isTrue);
final transport = _MemoryTransport();
addTearDown(transport.close);
await client.reconnectWith(transport);
expect(client.isConnected, isTrue);
expect(client.socketPath, 'memory://test');
// Requests now flow over the new transport, not the old socket.
final lineFuture = transport.fromClient.stream.first;
final respFuture = client.request('over-memory');
final req = IpcMessage.decode(await lineFuture) as IpcRequest;
transport.toClient.add(IpcResponse.ok(id: req.id, data: const {}).encode());
expect((await respFuture).ok, isTrue);
});
});
}
+71 -19
View File
@@ -1,5 +1,5 @@
/// Unit tests for ModifierTapTracker — double-tapped bare-modifier
/// detection (T-341).
/// detection (T-341), clean-release semantics (T-409).
library;
import 'package:clide/kernel/src/keymap/key_chord.dart';
@@ -12,45 +12,97 @@ void main() {
final t0 = DateTime(2026, 1, 1, 12);
DateTime at(int ms) => t0.add(Duration(milliseconds: ms));
// A clean tap: bare press + release.
KeyModifier? tap(ModifierTapTracker t, KeyModifier m, int ms) {
t.down(m);
return t.up(m, at(ms));
}
group('ModifierTapTracker', () {
test('two taps of the same modifier within the window fire', () {
test('two clean taps of the same modifier within the window fire', () {
final t = ModifierTapTracker(window: const Duration(milliseconds: 350));
expect(t.tap(KeyModifier.shift, at(0)), isNull); // first tap arms
expect(t.tap(KeyModifier.shift, at(200)), KeyModifier.shift); // double-tap
expect(tap(t, KeyModifier.shift, 0), isNull); // first tap arms
expect(tap(t, KeyModifier.shift, 200), KeyModifier.shift); // double-tap
});
test('the second tap just outside the window does not fire', () {
final t = ModifierTapTracker(window: const Duration(milliseconds: 350));
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.shift, at(400)), isNull); // too slow
expect(tap(t, KeyModifier.shift, 0), isNull);
expect(tap(t, KeyModifier.shift, 400), isNull); // too slow
});
test('a slow second tap re-arms, so a prompt third tap fires', () {
final t = ModifierTapTracker(window: const Duration(milliseconds: 350));
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.shift, at(500)), isNull); // re-arms from here
expect(t.tap(KeyModifier.shift, at(600)), KeyModifier.shift);
expect(tap(t, KeyModifier.shift, 0), isNull);
expect(tap(t, KeyModifier.shift, 500), isNull); // re-arms from here
expect(tap(t, KeyModifier.shift, 600), KeyModifier.shift);
});
test('different modifiers never form a double-tap', () {
final t = ModifierTapTracker();
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.ctrl, at(100)), isNull); // ctrl != shift
expect(tap(t, KeyModifier.shift, 0), isNull);
expect(tap(t, KeyModifier.ctrl, 100), isNull); // ctrl != shift
});
test('an intervening key (reset) breaks the gesture', () {
test('an intervening key between taps breaks the gesture', () {
final t = ModifierTapTracker();
expect(t.tap(KeyModifier.shift, at(0)), isNull);
t.reset(); // e.g. a letter was pressed: Shift a Shift
expect(t.tap(KeyModifier.shift, at(100)), isNull);
expect(tap(t, KeyModifier.shift, 0), isNull);
t.down(null); // a letter: Shift a Shift
t.up(null, at(50));
expect(tap(t, KeyModifier.shift, 100), isNull);
});
test('firing consumes the pair — a third tap re-arms, not re-fires', () {
final t = ModifierTapTracker();
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.shift, at(100)), KeyModifier.shift); // fires + resets
expect(t.tap(KeyModifier.shift, at(150)), isNull); // back to arming
expect(t.tap(KeyModifier.shift, at(200)), KeyModifier.shift);
expect(tap(t, KeyModifier.shift, 0), isNull);
expect(tap(t, KeyModifier.shift, 100), KeyModifier.shift); // fires + resets
expect(tap(t, KeyModifier.shift, 150), isNull); // back to arming
expect(tap(t, KeyModifier.shift, 200), KeyModifier.shift);
});
// T-409 regression: a chorded press (Shift+; typing a colon) is not a tap.
test('a key chorded onto a held modifier dirties the press', () {
final t = ModifierTapTracker();
t.down(KeyModifier.shift);
t.down(null); // `;` while Shift held — typing `:`
t.up(null, at(50));
expect(t.up(KeyModifier.shift, at(80)), isNull); // dirty press, no tap
});
test('typing two colons rapidly never fires', () {
final t = ModifierTapTracker();
for (final base in [0, 120]) {
t.down(KeyModifier.shift);
t.down(null);
t.up(null, at(base + 40));
expect(t.up(KeyModifier.shift, at(base + 60)), isNull);
}
});
test('a chorded press also breaks an armed first tap', () {
final t = ModifierTapTracker();
expect(tap(t, KeyModifier.shift, 0), isNull); // clean tap arms
t.down(KeyModifier.shift);
t.down(null); // Shift+; — chord, must disarm
t.up(null, at(40));
expect(t.up(KeyModifier.shift, at(60)), isNull);
// The next single clean tap re-arms but must not fire either.
expect(tap(t, KeyModifier.shift, 100), isNull);
});
test('a second modifier chorded onto the first is not a tap', () {
final t = ModifierTapTracker();
t.down(KeyModifier.shift);
t.down(KeyModifier.ctrl); // ctrl while shift held
expect(t.up(KeyModifier.ctrl, at(30)), isNull);
expect(t.up(KeyModifier.shift, at(50)), isNull);
});
test('a release without a tracked press is ignored', () {
final t = ModifierTapTracker();
expect(t.up(KeyModifier.shift, at(0)), isNull); // stale release
expect(tap(t, KeyModifier.shift, 50), isNull); // arms normally after
expect(tap(t, KeyModifier.shift, 150), KeyModifier.shift);
});
});
}
@@ -0,0 +1,81 @@
/// Widget tests for PaneKeyNav (T-406): the per-pane vim-normal key handler
/// that runs its own SequenceMatcher and dispatches nav.* intents — proven
/// end-to-end against the real vim preset and scope flags.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../helpers/kernel_fixture.dart';
import '../../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<List<NavIntent>> pump(WidgetTester tester, {required Map<String, bool> scope}) async {
// setPreset does real asset + keybindings-file I/O; run it outside the
// fake-async zone or the testWidgets body hangs (the T-122 lesson).
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
for (final e in scope.entries) {
f.services.keymap.setScopeFlag(e.key, e.value);
}
final got = <NavIntent>[];
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(
harness(f, PaneKeyNav(focusNode: node, autofocus: true, onNav: (i, _) => got.add(i), child: const SizedBox(width: 100, height: 100))),
);
node.requestFocus();
await tester.pump();
return got;
}
testWidgets('bare motions dispatch nav.* under vim.normal (pane focused)', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
expect(got, [isA<NavDownIntent>(), isA<NavUpIntent>(), isA<NavCollapseOrLeftIntent>(), isA<NavExpandOrRightIntent>()]);
});
testWidgets('gg sequence resolves to nav.top', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
expect(got, [isA<NavTopIntent>()]);
});
testWidgets('ctrl+d / ctrl+u are claimed as half-page nav', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(got, [isA<NavPageDownIntent>(), isA<NavPageUpIntent>()]);
});
testWidgets('the editor.focused guard suppresses nav (keys go to the editor)', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true, 'editor.focused': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
// j/k now resolve to editor.vim.* — not NavIntents — so onNav never fires.
expect(got, isEmpty);
});
testWidgets('keys pass through outside vim normal mode', (tester) async {
final got = await pump(tester, scope: {'vim.insert': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
expect(got, isEmpty);
});
testWidgets('an unbound bare key is swallowed without dispatching nav', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyZ);
expect(got, isEmpty);
});
}
Binary file not shown.
+31
View File
@@ -272,5 +272,36 @@ void main() {
expect(calls, 1);
n.dispose();
});
// T-382: the in-memory list had no widget consumer — notifications
// vanished silently. Wired to the bus, each one now raises a toast.
test('a bus-wired notification surfaces as a rendered toast (T-382)', () async {
final messages = MessageBus();
final toasts = ToastService(messages: messages);
addTearDown(toasts.dispose);
final n = Notifications(messages: messages);
addTearDown(n.dispose);
n.warn('clide CLI not on PATH', title: 'dogfood');
await Future<void>.delayed(Duration.zero);
expect(toasts.entries, hasLength(1));
expect(toasts.entries.single.message, 'dogfood — clide CLI not on PATH');
expect(toasts.entries.single.severity, ToastSeverity.warning);
});
test('error and success levels map to their toast severities (T-382)', () async {
final messages = MessageBus();
final toasts = ToastService(messages: messages);
addTearDown(toasts.dispose);
final n = Notifications(messages: messages);
addTearDown(n.dispose);
n.error('boom');
n.success('done');
await Future<void>.delayed(Duration.zero);
expect(toasts.entries.map((e) => e.severity), [ToastSeverity.error, ToastSeverity.success]);
});
});
}
+37
View File
@@ -170,6 +170,43 @@ void main() {
expect(store.get<int>('app.anything'), isNull);
});
// T-376: maps nested inside lists were emitted via toString() and
// corrupted on the next read — breaking the documented keymap overlay.
test('maps inside lists round-trip across save/load (keymap overlay shape)', () async {
final overlay = [
{'keys': 'ctrl+k ctrl+s', 'command': 'keybindings.open'},
{'keys': 'shift shift', 'command': 'finder.open', 'when': 'editorFocus'},
];
await store.set<Object>('app.keymap.overlay', overlay);
final loaded = SettingsStore(appDir: tmp);
addTearDown(loaded.dispose);
await loaded.load();
final got = loaded.get<List>('app.keymap.overlay');
expect(got, hasLength(2));
expect((got![0] as Map)['keys'], 'ctrl+k ctrl+s');
expect((got[0] as Map)['command'], 'keybindings.open');
expect((got[1] as Map)['when'], 'editorFocus');
});
test('a parse failure preserves the original file and reports it (T-376)', () async {
final errors = <String>[];
final f = File('${tmp.path}/settings.yaml');
const garbage = 'app:\n broken: [unclosed\n'; // genuinely invalid YAML
await f.writeAsString(garbage);
final reporting = SettingsStore(appDir: tmp, onError: errors.add);
addTearDown(reporting.dispose);
await reporting.load();
expect(errors, hasLength(1));
expect(errors.single, contains('.broken'));
expect(File('${f.path}.broken').readAsStringSync(), garbage, reason: 'the broken original is preserved for recovery');
});
test('writes are atomic — no .tmp residue, content lands whole', () async {
await store.set<String>('app.k', 'v');
expect(File('${tmp.path}/settings.yaml.tmp').existsSync(), isFalse);
expect(File('${tmp.path}/settings.yaml').readAsStringSync(), contains('k: v'));
});
test('load returns empty when the settings file is blank or missing', () async {
// File missing → empty.
final f = File('${tmp.path}/settings.yaml');
+78
View File
@@ -0,0 +1,78 @@
/// Unit tests for `WorkspaceRef` (T-332) — local/remote workspace
/// identity and the `ssh://[user@]host[:port]/abs/path` open scheme.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:test/test.dart';
void main() {
group('WorkspaceRef.parse — local', () {
test('a bare path is a local ref', () {
final ref = WorkspaceRef.parse('/var/repo');
expect(ref, const WorkspaceRef.local('/var/repo'));
expect(ref!.isRemote, isFalse);
expect(ref.uri, '/var/repo');
expect(ref.display, '/var/repo');
});
test('a relative path stays a local ref verbatim', () {
expect(WorkspaceRef.parse('repo'), const WorkspaceRef.local('repo'));
});
});
group('WorkspaceRef.parse — ssh://', () {
test('host + path', () {
final ref = WorkspaceRef.parse('ssh://buildbox/srv/repo');
expect(ref, WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo'));
expect(ref!.isRemote, isTrue);
expect(ref.port, isNull);
expect(ref.user, isNull);
});
test('user@host:port + path', () {
final ref = WorkspaceRef.parse('ssh://jeroen@buildbox:2222/srv/repo');
expect(ref!.user, 'jeroen');
expect(ref.host, 'buildbox');
expect(ref.port, 2222);
expect(ref.path, '/srv/repo');
});
test('uri round-trips through parse', () {
const refs = [
WorkspaceRef.local('/var/repo'),
WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo'),
WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo', port: 2222, user: 'jeroen'),
];
for (final ref in refs) {
expect(WorkspaceRef.parse(ref.uri), ref, reason: ref.uri);
}
});
test('display is host:path', () {
expect(WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo').display, 'buildbox:/srv/repo');
});
test('missing host or missing path is rejected', () {
expect(WorkspaceRef.parse('ssh:///srv/repo'), isNull);
expect(WorkspaceRef.parse('ssh://buildbox'), isNull);
expect(WorkspaceRef.parse('ssh://buildbox/'), isNull);
});
test('garbage after the scheme is rejected, not crashed on', () {
expect(WorkspaceRef.parse('ssh://[::bad'), isNull);
});
});
group('WorkspaceRef equality', () {
test('value equality + hashCode', () {
expect(WorkspaceRef.remote(host: 'h', path: '/p'), WorkspaceRef.remote(host: 'h', path: '/p'));
expect(WorkspaceRef.remote(host: 'h', path: '/p').hashCode, WorkspaceRef.remote(host: 'h', path: '/p').hashCode);
expect(WorkspaceRef.remote(host: 'h', path: '/p'), isNot(const WorkspaceRef.local('/p')));
expect(WorkspaceRef.remote(host: 'h', path: '/p', port: 22), isNot(WorkspaceRef.remote(host: 'h', path: '/p')));
});
test('toString carries the uri form', () {
expect(WorkspaceRef.remote(host: 'h', path: '/p').toString(), contains('ssh://h/p'));
});
});
}
+36
View File
@@ -154,6 +154,42 @@ void main() {
expect(s.pid, greaterThan(0));
});
test('master fd is released after natural child exit', tags: ['pty'], () async {
// Linux-only: counts open fds resolving to /dev/ptmx via /proc.
// A naturally-exited child must not leave the master fd open —
// _reap() owns the release because close() short-circuits on
// _dead (T-360).
if (!Platform.isLinux) return;
int ptmxCount() => Directory('/proc/self/fd').listSync().where((e) {
try {
return Link(e.path).targetSync() == '/dev/ptmx';
} on FileSystemException {
return false; // fd vanished between list and readlink
}
}).length;
final baseline = ptmxCount();
final s = NativePty.start(
executable: '/bin/sh',
arguments: ['-c', 'exit 0'],
columns: 80,
rows: 24,
workingDirectory: '/',
environment: {...Platform.environment, 'TERM': 'xterm-256color'},
);
addTearDown(s.close);
final done = Completer<void>();
s.output.listen((_) {}, onDone: () => done.complete());
await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s after child exit'));
// EOF closes the output stream from the same listener callback
// that runs _reap(), so the fd is already released here.
expect(s.isClosed, isTrue);
expect(ptmxCount(), baseline);
});
test('resize on a live PTY does not throw', () async {
final s = NativePty.start(
executable: '/bin/sh',
+24
View File
@@ -104,6 +104,30 @@ void main() {
);
expect(r.any((f) => f.path == 'blob.bin'), isFalse);
});
// T-364: the globs were accepted and silently dropped — replace touched
// files the equivalent search would never have matched.
test('include glob restricts replacement to matching files', () async {
File('${root.path}/c.txt').writeAsStringSync('foo here too\n');
final r = await computeReplacements(
root: root,
ignore: IgnoreSet([]),
query: const SearchQuery(pattern: 'foo', include: ['*.dart']),
replacement: 'baz',
);
expect(r.map((f) => f.path).toList(), ['a.dart']);
});
test('exclude glob is honored', () async {
File('${root.path}/c.txt').writeAsStringSync('foo here too\n');
final r = await computeReplacements(
root: root,
ignore: IgnoreSet([]),
query: const SearchQuery(pattern: 'foo', exclude: ['*.dart']),
replacement: 'baz',
);
expect(r.map((f) => f.path).toList(), ['c.txt']);
});
});
group('rewriteFileContent', () {
+89
View File
@@ -676,6 +676,67 @@ void main() {
f.parser.write('\x1b[123m');
expect(f.h.named('unsupportedStyle').first.args, [123]);
});
// T-369: an emulator must never throw on hostile bytes. The old code did
// unguarded params[i+1] lookahead in 38/48 — `\x1b[38m` was a RangeError
// inside Terminal.write.
test('truncated 38/48 sequences are ignored, never throw', () {
final f = _newParser();
for (final s in [
'\x1b[38m',
'\x1b[48m',
'\x1b[38;2m',
'\x1b[38;2;255m',
'\x1b[38;2;255;10m',
'\x1b[38;5m',
'\x1b[48;5m',
'\x1b[38:2m',
'\x1b[38:5m',
'\x1b[48:2:255m',
]) {
f.parser.write(s);
}
expect(f.h.named('setForegroundColorRgb'), isEmpty);
expect(f.h.named('setBackgroundColorRgb'), isEmpty);
expect(f.h.named('setForegroundColor256'), isEmpty);
expect(f.h.named('setBackgroundColor256'), isEmpty);
});
test('colon-form truecolor matches semicolon form (ITU T.416)', () {
final f = _newParser();
f.parser.write('\x1b[38:2:10:20:30m\x1b[48:2:100:150:200m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]);
});
test('colon-form with empty colorspace slot — 38:2::r:g:b', () {
final f = _newParser();
f.parser.write('\x1b[38:2::10:20:30m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
});
test('colon-form 256-colour — 38:5:n', () {
final f = _newParser();
f.parser.write('\x1b[38:5:200m\x1b[48:5:42m');
expect(f.h.named('setForegroundColor256').first.args, [200]);
expect(f.h.named('setBackgroundColor256').first.args, [42]);
});
test('a malformed colon group is dropped whole, neighbours still apply', () {
final f = _newParser();
// The bogus 38:2:255 group must not bleed into the following bold.
f.parser.write('\x1b[38:2:255;1m');
expect(f.h.named('setForegroundColorRgb'), isEmpty);
expect(f.h.named('setCursorBold').length, 1);
});
test('extended color followed by more SGR params keeps positions', () {
final f = _newParser();
f.parser.write('\x1b[1;38;2;10;20;30;4m');
expect(f.h.named('setCursorBold').length, 1);
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setCursorUnderline').length, 1);
});
});
group('EscapeParser — OSC sequences', () {
@@ -740,6 +801,34 @@ void main() {
});
});
group('EscapeParser — CSI intermediate bytes (T-123)', () {
test('CSI 5 SP @ (SL) does not mis-dispatch as insert-blank-chars', () {
final f = _newParser();
f.parser.write('\x1b[5 @');
expect(f.h.named('insertBlankChars'), isEmpty);
expect(f.h.named('unknownCSI').first.args, ['@'.codeUnitAt(0)]);
});
test('CSI 4 SP q (DECSCUSR) routes to unknownCSI, not a bare-q handler', () {
final f = _newParser();
f.parser.write('\x1b[4 q');
expect(f.h.named('unknownCSI').first.args, ['q'.codeUnitAt(0)]);
});
test('CSI ! p (DECSTR) routes to unknownCSI', () {
final f = _newParser();
f.parser.write('\x1b[!p');
expect(f.h.named('unknownCSI').first.args, ['p'.codeUnitAt(0)]);
});
test('intermediates reset between sequences', () {
final f = _newParser();
f.parser.write('\x1b[5 @'); // intermediate form → unknownCSI
f.parser.write('\x1b[3@'); // plain form must dispatch normally again
expect(f.h.named('insertBlankChars').first.args, [3]);
});
});
group('EscapeParser — token bookkeeping', () {
test('tokenBegin / tokenEnd advance with consumed bytes', () {
final f = _newParser();
+25
View File
@@ -6,6 +6,8 @@
/// dependency.
library;
import 'dart:convert' show utf8;
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/input/keys.dart';
import 'package:clide/src/terminal/src/core/mouse/button.dart';
@@ -121,6 +123,29 @@ void main() {
t.write('\x1b[31mred'); // SGR red foreground
expect(t.cursor.foreground, isNot(0)); // foreground was set
});
// T-373: byte consumers used to utf8.decode per chunk — a rune split
// across PTY reads rendered as U+FFFD garbage.
test('writeBytes joins a multi-byte rune split across two calls', () {
final t = _Recorder().build();
final euro = utf8.encode(''); // 3 bytes: E2 82 AC
t.writeBytes(euro.sublist(0, 1));
t.writeBytes(euro.sublist(1));
expect(t.buffer.lines[0].getCodePoint(0), ''.codeUnitAt(0));
});
test('writeBytes decodes consecutive whole chunks like write', () {
final t = _Recorder().build();
t.writeBytes(utf8.encode('héllo'));
final line = [for (var i = 0; i < 5; i++) t.buffer.lines[0].getCodePoint(i)];
expect(String.fromCharCodes(line), 'héllo');
});
test('writeBytes with an empty chunk is a no-op', () {
final t = _Recorder().build();
t.writeBytes(const []);
expect(t.buffer.cursorX, 0);
});
});
group('Terminal — keyInput', () {
+117
View File
@@ -0,0 +1,117 @@
/// Tests for [ValueStream] — the replay-latest state holder (T-386).
library;
import 'package:clide/src/util/value_stream.dart';
import 'package:test/test.dart';
void main() {
test('a late subscriber receives the latest value immediately', () async {
final v = ValueStream<int>();
v.add(1);
v.add(2);
final got = <int>[];
v.stream.listen(got.add);
await Future<void>.delayed(Duration.zero);
expect(got, [2]);
});
test('an unseeded holder replays nothing until the first add', () async {
final v = ValueStream<int>();
final got = <int>[];
v.stream.listen(got.add);
await Future<void>.delayed(Duration.zero);
expect(got, isEmpty);
v.add(7);
await Future<void>.delayed(Duration.zero);
expect(got, [7]);
});
test('seeded constructor provides the initial value', () async {
final v = ValueStream<bool>.seeded(false);
expect(v.hasValue, isTrue);
expect(v.value, isFalse);
final got = <bool>[];
v.stream.listen(got.add);
await Future<void>.delayed(Duration.zero);
expect(got, [false]);
});
test('live updates flow to existing subscribers', () async {
final v = ValueStream<String>();
final got = <String>[];
v.stream.listen(got.add);
v.add('a');
v.add('b');
await Future<void>.delayed(Duration.zero);
expect(got, ['a', 'b']);
});
test('each stream access gives every subscriber its own replay', () async {
final v = ValueStream<int>.seeded(5);
final a = <int>[];
final b = <int>[];
v.stream.listen(a.add);
v.stream.listen(b.add);
v.add(6);
await Future<void>.delayed(Duration.zero);
expect(a, [5, 6]);
expect(b, [5, 6]);
});
test('value throws before the first add; valueOrNull is null', () {
final v = ValueStream<int>();
expect(() => v.value, throwsStateError);
expect(v.valueOrNull, isNull);
expect(v.hasValue, isFalse);
});
test('a nullable type can hold null as a real value', () async {
final v = ValueStream<String?>.seeded(null);
expect(v.hasValue, isTrue);
expect(v.valueOrNull, isNull);
final got = <String?>[];
v.stream.listen(got.add);
await Future<void>.delayed(Duration.zero);
expect(got, [null]);
});
test('close ends derived streams; a post-close subscriber still gets the replay', () async {
final v = ValueStream<int>();
v.add(3);
final done = <String>[];
v.stream.listen((_) {}, onDone: () => done.add('a'));
await v.close();
await Future<void>.delayed(Duration.zero);
expect(done, ['a']);
expect(v.isClosed, isTrue);
final got = <int>[];
var closed = false;
v.stream.listen(got.add, onDone: () => closed = true);
await Future<void>.delayed(Duration.zero);
expect(got, [3], reason: 'the last value survives close for late readers');
expect(closed, isTrue);
});
test('add after close updates the value without throwing', () {
final v = ValueStream<int>.seeded(1);
v.close();
v.add(2);
expect(v.value, 2);
});
test('pause/resume on a derived stream buffers updates', () async {
final v = ValueStream<int>();
final got = <int>[];
final sub = v.stream.listen(got.add);
v.add(1);
await Future<void>.delayed(Duration.zero);
sub.pause();
v.add(2);
await Future<void>.delayed(Duration.zero);
expect(got, [1]);
sub.resume();
await Future<void>.delayed(Duration.zero);
expect(got, [1, 2]);
});
}
+55
View File
@@ -0,0 +1,55 @@
/// ClideIconRail identity tint (T-418): an item's iconColor overrides the
/// state colours — full-strength when active or hovered, dimmed when idle.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart';
import '../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
const tint = Color(0xFFD97757);
final items = [
ClideIconRailItem(id: 'claude', icon: PhosphorIcons.byName('robot'), tooltip: 'Claude', iconColor: tint),
ClideIconRailItem(id: 'files', icon: PhosphorIcons.byName('folder'), tooltip: 'Files'),
];
Future<void> pump(WidgetTester tester, {required String activeId}) async {
await tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 300,
height: 40,
child: ClideIconRail(items: items, activeId: activeId, onSelect: (_) {}),
),
),
),
);
await tester.pump();
}
ClideIcon iconFor(WidgetTester tester, String tooltip) =>
tester.widget<ClideIcon>(find.descendant(of: find.bySemanticsLabel(tooltip), matching: find.byType(ClideIcon)));
testWidgets('an active tinted item renders the tint full-strength', (tester) async {
await pump(tester, activeId: 'claude');
expect(iconFor(tester, 'Claude').color, tint);
});
testWidgets('an idle tinted item renders the tint dimmed, untinted items keep state colours', (tester) async {
await pump(tester, activeId: 'files');
final claude = iconFor(tester, 'Claude').color!;
expect(claude.toARGB32() & 0x00FFFFFF, tint.toARGB32() & 0x00FFFFFF); // same hue
expect(claude.a, lessThan(1.0)); // dimmed while idle
expect(iconFor(tester, 'Files').color, isNot(tint)); // untinted untouched
});
}
@@ -155,6 +155,25 @@ void main() {
);
});
// T-370: the summarized button semantics used to wrap the WHOLE card with
// excludeSemantics, wiping every expanded child from the a11y tree — a
// screen-reader user could expand a run and hear nothing inside it.
testWidgets('expanded children are present in the semantics tree (T-370)', (tester) async {
final handle = tester.ensureSemantics();
await pump(tester, expanded: true);
expect(find.bySemanticsLabel('Edits, 3 edits, expanded'), findsOneWidget);
expect(find.bySemanticsLabel('item body'), findsOneWidget, reason: 'expanded content must be readable by AT');
handle.dispose();
});
testWidgets('collapsed children are absent from the semantics tree, header summarizes (T-370)', (tester) async {
final handle = tester.ensureSemantics();
await pump(tester);
expect(find.bySemanticsLabel('Edits, 3 edits, collapsed'), findsOneWidget);
expect(find.bySemanticsLabel('item body'), findsNothing);
handle.dispose();
});
testWidgets('no counter + no status renders a bare ticker without error', (tester) async {
await pump(tester, counter: null, status: null, summary: null);
expect(find.text('Edits'), findsOneWidget);
+22
View File
@@ -49,4 +49,26 @@ void main() {
expect(find.textContaining('https://example.com'), findsOneWidget);
expect(tester.takeException(), isNull);
});
// T-379: hard breaks and images fell through to empty text spans —
// words glued together, images vanished without a trace.
testWidgets('a hard line break splits the line instead of gluing words', (tester) async {
// Two trailing spaces = a markdown hard break.
await tester.pumpWidget(harness(f, const ClideMarkdown('alpha \nbeta')));
await tester.pump();
expect(find.textContaining('alpha\nbeta'), findsOneWidget);
expect(find.textContaining('alphabeta'), findsNothing);
});
testWidgets('an image renders its alt text as a visible placeholder', (tester) async {
await tester.pumpWidget(harness(f, const ClideMarkdown('before ![a diagram](http://x/y.png) after')));
await tester.pump();
expect(find.textContaining('[image: a diagram]'), findsOneWidget);
});
testWidgets('an image with no alt text falls back to its source', (tester) async {
await tester.pumpWidget(harness(f, const ClideMarkdown('![](http://x/y.png)')));
await tester.pump();
expect(find.textContaining('[image: http://x/y.png]'), findsOneWidget);
});
}
+1 -37
View File
@@ -1,11 +1,10 @@
/// Smoke + branch tests for the previously-uncovered widgets under
/// `lib/widgets/src/`: ClidePalette, ClideFilterBox, ColumnHat,
/// `lib/widgets/src/`: ClidePalette, ClideFilterBox,
/// ClideIconRail, ClideSpine, ClideResizeBorder.
library;
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/widgets/src/clide_column_hat.dart';
import 'package:clide/widgets/src/clide_filter_box.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_icon_rail.dart';
@@ -311,41 +310,6 @@ void main() {
});
});
group('ColumnHat', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('left / center / right factories render', (tester) async {
final wc = WindowControls();
addTearDown(wc.dispose);
await tester.pumpWidget(
harness(
f,
Column(
children: [
SizedBox(width: 200, child: ColumnHat.left(windowControls: wc)),
SizedBox(
width: 200,
child: ColumnHat.center(windowControls: wc, project: 'clide', branch: 'main'),
),
SizedBox(width: 200, child: ColumnHat.right(windowControls: wc)),
],
),
),
);
// Center hat renders the joined label.
expect(find.text('clide > main'), findsOneWidget);
});
testWidgets('center hat falls back to "clide" with no project/branch', (tester) async {
final wc = WindowControls();
addTearDown(wc.dispose);
await tester.pumpWidget(harness(f, SizedBox(width: 200, child: ColumnHat.center(windowControls: wc))));
expect(find.text('clide'), findsOneWidget);
});
});
group('ClideIconRail', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());