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:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)', () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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  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('')));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('[image: http://x/y.png]'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user